Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 66 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ import {
writeMetadata,
atomicWriteFileSync,
getSessionDir,
getSocketPath,
DEFAULT_SESSION_DIR,
type SessionInfo,
} from "./sessions.ts";
import * as net from "node:net";
import { encodeResizeAuthoritative } from "./protocol.ts";
import { spawnDaemon, resolveCommand } from "./spawn.ts";
import {
EventFollower, EventWriter, EventType,
Expand Down Expand Up @@ -351,6 +354,7 @@ async function main(): Promise<void> {
let explicitId: string | null = null;
let explicitDisplayName: string | null = null;
let cwd: string | null = null;
let size: { cols: number; rows: number } | null = null;
const tags: Record<string, string> = {};
let i = 1;
while (i < args.length && args[i] !== "--") {
Expand All @@ -363,6 +367,15 @@ async function main(): Promise<void> {
else if (args[i] === "--id" && i + 1 < args.length) { explicitId = args[i + 1]; i += 2; }
else if (args[i] === "--name" && i + 1 < args.length) { explicitDisplayName = args[i + 1]; i += 2; }
else if (args[i] === "--cwd" && i + 1 < args.length) { cwd = args[i + 1]; i += 2; }
else if (args[i] === "--size" && i + 1 < args.length) {
const m = /^(\d+)x(\d+)$/.exec(args[i + 1]);
if (!m) {
console.error(`Invalid --size "${args[i + 1]}". Use --size <cols>x<rows>, e.g. --size 120x40`);
process.exit(1);
}
size = { cols: parseInt(m[1], 10), rows: parseInt(m[2], 10) };
i += 2;
}
else if (args[i] === "--tag" && i + 1 < args.length) {
const eq = args[i + 1].indexOf("=");
if (eq === -1) {
Expand Down Expand Up @@ -523,27 +536,29 @@ async function main(): Promise<void> {
}
}

await cmdRun(name, cmd, cmdArgs, detach, attachExisting, displayCmd, ephemeral, tags, cwd, isolateEnv, displayName);
await cmdRun(name, cmd, cmdArgs, detach, attachExisting, displayCmd, ephemeral, tags, cwd, isolateEnv, displayName, size);
break;
}

case "attach":
case "a": {
let autoRestart = false;
let force = false;
let geometryNeutral = false;
let attachName: string | null = null;
for (let ai = 1; ai < args.length; ai++) {
const a = args[ai];
if (a === "--auto-restart" || a === "-r") autoRestart = true;
else if (a === "--force") force = true;
else if (a === "--no-resize" || a === "--neutral") geometryNeutral = true;
else if (!attachName) attachName = a;
else {
console.error(`pty attach: unexpected argument "${a}"`);
process.exit(1);
}
}
if (!attachName) {
console.error("Usage: pty attach [-r|--auto-restart] [--force] <name>");
console.error("Usage: pty attach [-r|--auto-restart] [--no-resize] [--force] <name>");
process.exit(1);
}
// Nesting guard runs BEFORE name validation / ref resolution. A nested
Expand All @@ -558,7 +573,22 @@ async function main(): Promise<void> {
" Pass --force to attach anyway (nested clients are usually a mistake).",
});
const resolvedAttachName = await resolveRef(attachName);
await cmdAttach(resolvedAttachName, autoRestart, force);
await cmdAttach(resolvedAttachName, autoRestart, force, geometryNeutral);
break;
}

case "resize": {
// pty resize <name> <cols>x<rows> — set the authoritative session-owned
// geometry. Works on any session; pins it going forward.
const resizeName = args[1];
const spec = args[2];
const m = spec ? /^(\d+)x(\d+)$/.exec(spec) : null;
if (!resizeName || !m) {
console.error("Usage: pty resize <name> <cols>x<rows>");
process.exit(1);
}
const resolved = await resolveRef(resizeName);
await cmdResize(resolved, parseInt(m[1], 10), parseInt(m[2], 10));
break;
}

Expand Down Expand Up @@ -1080,6 +1110,7 @@ async function cmdRun(
explicitCwd: string | null = null,
isolateEnv = false,
displayName: string | null = null,
size: { cols: number; rows: number } | null = null,
): Promise<void> {
const session = await getSession(name);
if (session?.status === "running") {
Expand Down Expand Up @@ -1121,6 +1152,8 @@ async function cmdRun(
name, command, args, displayCommand, cwd: cwdOpt, ephemeral, tags: tagOpt,
...(displayNameOpt ? { displayName: displayNameOpt } : {}),
...(isolateEnv ? { isolateEnv: true } : {}),
// `--size` authors an authoritative, pinned session geometry.
...(size ? { rows: size.rows, cols: size.cols, pinGeometry: true } : {}),
});
} finally {
releaseLock(name);
Expand All @@ -1139,6 +1172,7 @@ async function cmdAttach(
name: string,
autoRestart = false,
_force = false,
geometryNeutral = false,
): Promise<void> {
// Nesting guard runs in the dispatcher (before name resolution) so the
// user gets the nesting hint even for typo'd refs. cmdAttach itself is
Expand All @@ -1153,7 +1187,7 @@ async function cmdAttach(
}

if (session.status === "running") {
doAttach(name);
doAttach(name, geometryNeutral);
return;
}

Expand Down Expand Up @@ -1207,9 +1241,36 @@ async function handleDeadSession(
doAttach(session.name);
}

function doAttach(name: string): void {
/** Send an authoritative RESIZE to a running session and pin its geometry. */
async function cmdResize(name: string, cols: number, rows: number): Promise<void> {
if (cols <= 0 || rows <= 0) {
console.error(`Invalid size ${cols}x${rows}.`);
process.exit(1);
}
const socketPath = getSocketPath(name);
await new Promise<void>((resolve) => {
const socket = net.createConnection(socketPath);
socket.on("connect", () => {
socket.write(encodeResizeAuthoritative(rows, cols));
socket.end();
});
socket.on("finish", () => resolve());
socket.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT" || err.code === "ECONNREFUSED") {
console.error(`Session "${name}" not found or not running.`);
} else {
console.error(`Connection error: ${err.message}`);
}
process.exit(1);
});
});
console.log(`Session "${name}" resized to ${cols}x${rows} (session-owned).`);
}

function doAttach(name: string, geometryNeutral = false): void {
attach({
name,
geometryNeutral,
onDetach: () => process.exit(0),
onExit: (code) => process.exit(code),
});
Expand Down
19 changes: 15 additions & 4 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,11 @@ export function queryStats(name: string, timeoutMs = 2000): Promise<StatsResult>

export interface AttachOptions {
name: string;
/** Non-disturbing attach (`pty attach --no-resize`). Forwards input but does
* NOT contribute the client's geometry and does NOT nudge the child. The
* session renders in its own coordinate space; the client letterboxes/pans
* locally. Backward-compatible: sends a 5-byte ATTACH flags frame. */
geometryNeutral?: boolean;
onExit?: (code: number) => void;
onDetach?: () => void;
}
Expand Down Expand Up @@ -339,10 +344,12 @@ export function attach(options: AttachOptions): void {
socket.on("connect", () => {
enterRawMode();

// Tell the server our terminal size
// Tell the server our terminal size. In geometry-neutral mode the size is
// advisory only (the server ignores it for negotiation) — sent so the flags
// byte rides along on a well-formed 5-byte ATTACH frame.
const rows = (stdout as tty.WriteStream).rows ?? 24;
const cols = (stdout as tty.WriteStream).columns ?? 80;
socket.write(encodeAttach(rows, cols));
socket.write(encodeAttach(rows, cols, options.geometryNeutral === true));

// Forward stdin to server
// Double Ctrl+\ passthrough: press once = detach, press twice quickly = send Ctrl+\ to process
Expand Down Expand Up @@ -399,8 +406,12 @@ export function attach(options: AttachOptions): void {
// previously consumed stdin.
stdin.resume();

// Handle terminal resize
if (stdout instanceof tty.WriteStream) {
// Handle terminal resize. Skipped entirely for geometry-neutral clients:
// they never renegotiate the session's size, so local terminal resizes stay
// local (the client re-letterboxes its own viewport). Even if this handler
// were left on, negotiateSize() ignores neutral clients server-side — but
// not sending RESIZE avoids the wasted round-trips.
if (stdout instanceof tty.WriteStream && options.geometryNeutral !== true) {
resizeHandler = () => {
const rows = stdout.rows;
const cols = stdout.columns;
Expand Down
28 changes: 26 additions & 2 deletions src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,37 @@ export function encodeData(data: string): Buffer {
return encodePacket(MessageType.DATA, Buffer.from(data));
}

export function encodeAttach(rows: number, cols: number): Buffer {
const payload = Buffer.alloc(4);
// Optional flags byte appended to an ATTACH/RESIZE payload. Backward-compatible:
// the server guards `payload.length < 4` and `decodeSize` reads only bytes 0-3,
// so a legacy 4-byte frame is byte-identical and an old server ignores byte 4.
export const ATTACH_FLAG_GEOMETRY_NEUTRAL = 0x01; // input yes, geometry no, nudge no
export const RESIZE_FLAG_AUTHORITATIVE = 0x01; // `pty resize`: set session-owned size

export function encodeAttach(
rows: number,
cols: number,
geometryNeutral = false
): Buffer {
const payload = Buffer.alloc(geometryNeutral ? 5 : 4);
payload.writeUInt16BE(rows, 0);
payload.writeUInt16BE(cols, 2);
if (geometryNeutral) payload.writeUInt8(ATTACH_FLAG_GEOMETRY_NEUTRAL, 4);
return encodePacket(MessageType.ATTACH, payload);
}

/** Read the optional flags byte from an ATTACH/RESIZE payload (0 if absent). */
export function decodeFlags(payload: Buffer): number {
return payload.length >= 5 ? payload.readUInt8(4) : 0;
}

export function encodeResizeAuthoritative(rows: number, cols: number): Buffer {
const payload = Buffer.alloc(5);
payload.writeUInt16BE(rows, 0);
payload.writeUInt16BE(cols, 2);
payload.writeUInt8(RESIZE_FLAG_AUTHORITATIVE, 4);
return encodePacket(MessageType.RESIZE, payload);
}

export function encodeDetach(): Buffer {
return encodePacket(MessageType.DETACH, Buffer.alloc(0));
}
Expand Down
Loading