From d81a00fdd8b0022916aebb9ed5cd1a8b892e4ae0 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:59:10 +0200 Subject: [PATCH 1/3] perf(attach): skip the redraw nudge when the client is already the right size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The attach-time nudge (resize to cols-1 and back) exists to paper over serialize-replay artifacts. It fired on every attach, so connecting a viewer delivered SIGWINCH to an otherwise idle child — including the common case where the client attaches at exactly the size the session already has, where the child is by definition already drawn correctly. Nudge only when the attaching client's geometry differs from the session's, read before negotiateSize() so a smaller client that shrinks the session doesn't then look like it matched. `--force-resize` opts back in for children whose replay needs the redraw regardless. encodeAttach() now takes a flags bitmask rather than a geometryNeutral boolean, and the attach CLI path passes an AttachModes object instead of a fourth positional boolean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Lw7FSfYArhebq26GUb1kHi --- CHANGELOG.md | 4 ++++ README.md | 1 + completions/pty.bash | 2 +- completions/pty.fish | 1 + completions/pty.zsh | 1 + src/cli.ts | 39 ++++++++++++++++++++++++-------------- src/client.ts | 10 +++++++++- src/completions.ts | 1 + src/protocol.ts | 18 +++++++++--------- src/server.ts | 18 ++++++++++++++++-- tests/integration.test.ts | 40 ++++++++++++++++++++++++++++++++++++--- tests/protocol.test.ts | 17 +++++++++++++++-- 12 files changed, 120 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cba5a9..77a8bd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.12.0 +### Attach no longer nudges a child that is already at the right size + +- The attach-time redraw nudge (resize to `cols - 1` and back, to paper over serialize-replay artifacts) now fires only when the attaching client's geometry differs from the session's. Attaching at the size the session already has leaves the child alone, so connecting a viewer no longer delivers `SIGWINCH` to an idle process. `pty attach --force-resize` restores the nudge unconditionally for children whose replay needs it. + ### Package renamed: `@myobie/pty` → `@compoundingtech/pty` - The npm scope + GitHub org moved from `@myobie`/`myobie` to `@compoundingtech`/`compoundingtech`. The package is now **`@compoundingtech/pty`**; all subpaths (`/tui`, `/client`, `/server`, `/testing`, `/protocol`, `/keys`) and the CLI command (`pty`) are unchanged in shape. Update imports and any `@myobie/pty*` dependency specifiers. diff --git a/README.md b/README.md index aaea192..76ec1bf 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ pty list --filter-tag role=web # show only sessions with matching tag pty attach myserver # reconnect to a session pty attach -r myserver # reconnect, auto-restart if exited pty attach --no-resize myserver # interactive viewer; preserve shared PTY geometry +pty attach --force-resize myserver # nudge a redraw even at the current size pty exec -- codex # replace this session's process (inside a session) pty peek myserver # print current screen and exit pty peek --plain myserver # print as plain text (no ANSI) diff --git a/completions/pty.bash b/completions/pty.bash index d8ef426..1c2f214 100644 --- a/completions/pty.bash +++ b/completions/pty.bash @@ -28,7 +28,7 @@ _pty() { ;; attach|a) if [[ "${cur}" == -* ]]; then - COMPREPLY=($(compgen -W "-r --auto-restart --no-resize --force --remote" -- "${cur}")) + COMPREPLY=($(compgen -W "-r --auto-restart --no-resize --force-resize --force --remote" -- "${cur}")) else COMPREPLY=($(compgen -W "${names}" -- "${cur}")) fi diff --git a/completions/pty.fish b/completions/pty.fish index 0a40eb5..12dfcfe 100644 --- a/completions/pty.fish +++ b/completions/pty.fish @@ -76,6 +76,7 @@ complete -c pty -n '__pty_using_command run' -l isolate-env -d 'Scrub env to a s complete -c pty -n '__pty_using_command run' -l force -d 'Create even from inside another pty' complete -c pty -n '__pty_using_command attach a' -l auto-restart -s r -d 'Auto-restart if the session is exited' complete -c pty -n '__pty_using_command attach a' -l no-resize -d 'Preserve shared PTY geometry' +complete -c pty -n '__pty_using_command attach a' -l force-resize -d 'Redraw-nudge even at the current size' complete -c pty -n '__pty_using_command attach a' -l force -d 'Attach even from inside another pty' complete -c pty -n '__pty_using_command attach a' -l remote -d 'Attach a session on a fabric peer' complete -c pty -n '__pty_using_command attach a' -a '(__pty_sessions)' -d 'Session' diff --git a/completions/pty.zsh b/completions/pty.zsh index 0dc805b..2d06634 100644 --- a/completions/pty.zsh +++ b/completions/pty.zsh @@ -69,6 +69,7 @@ _pty() { _arguments \ '(r --auto-restart){r,--auto-restart}[Auto-restart if the session is exited]' \ '--no-resize[Preserve shared PTY geometry]' \ + '--force-resize[Redraw-nudge even at the current size]' \ '--force[Attach even from inside another pty]' \ '--remote[Attach a session on a fabric peer]' \ '1:session:_pty_sessions' diff --git a/src/cli.ts b/src/cli.ts index a70b7e1..35e51b3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -99,13 +99,15 @@ Examples: pty run -- node server.js pty run -d --name "API" --tag role=web -- node server.js`, - attach: `Usage: pty attach [-r] [--no-resize] [--force] [--remote ] + attach: `Usage: pty attach [-r] [--no-resize] [--force-resize] [--force] [--remote ] Reconnect to a session (alias: pty a). Detach again with Ctrl+\\. Flags: -r, --auto-restart Auto-restart the session if it has exited --no-resize Send input and receive output without changing PTY geometry + --force-resize Nudge the child into a redraw even when attaching at its + current size (that nudge is skipped by default) --force Attach even from inside another pty session (nested) --remote Attach a session on a fabric peer (over fabric); is the session's name/id ON THE REMOTE @@ -856,12 +858,14 @@ async function main(): Promise { let autoRestart = false; let force = false; let geometryNeutral = false; + let forceResize = false; let attachName: string | null = null; let attachRemotePeer: 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 === "--no-resize") geometryNeutral = true; + else if (a === "--force-resize") forceResize = true; else if (a === "--force") force = true; else if (a === "--remote" && ai + 1 < args.length) { attachRemotePeer = args[++ai]; } else if (!attachName) attachName = a; @@ -871,7 +875,7 @@ async function main(): Promise { } } if (!attachName) { - console.error("Usage: pty attach [-r|--auto-restart] [--no-resize] [--force] [--remote ] "); + console.error("Usage: pty attach [-r|--auto-restart] [--no-resize] [--force-resize] [--force] [--remote ] "); process.exit(1); } // Nesting guard runs BEFORE name validation / ref resolution. A nested @@ -887,15 +891,15 @@ async function main(): Promise { " Pass --force to attach anyway (nested clients are usually a mistake).", }); if (attachRemotePeer) { - if (geometryNeutral) { - console.error("pty attach: --no-resize is not yet supported with --remote"); + if (geometryNeutral || forceResize) { + console.error("pty attach: --no-resize/--force-resize are not yet supported with --remote"); process.exit(1); } // The name is the session's id ON THE REMOTE — don't resolve locally. await cmdAttachRemote(attachRemotePeer, attachName); } else { const resolvedAttachName = await resolveRef(attachName); - await cmdAttach(resolvedAttachName, autoRestart, force, geometryNeutral); + await cmdAttach(resolvedAttachName, autoRestart, force, { geometryNeutral, forceResize }); } break; } @@ -1549,11 +1553,17 @@ async function cmdRun( doAttach(name); } +/** Per-attach client behaviour that the daemon reads off the ATTACH frame. */ +interface AttachModes { + geometryNeutral?: boolean; + forceResize?: boolean; +} + async function cmdAttach( name: string, autoRestart = false, _force = false, - geometryNeutral = false, + modes: AttachModes = {}, ): Promise { // Nesting guard runs in the dispatcher (before name resolution) so the // user gets the nesting hint even for typo'd refs. cmdAttach itself is @@ -1568,13 +1578,13 @@ async function cmdAttach( } if (session.status === "running") { - if (geometryNeutral) await requireGeometryNeutralAttach(name); - doAttach(name, geometryNeutral); + if (modes.geometryNeutral) await requireGeometryNeutralAttach(name); + doAttach(name, modes); return; } // Dead session — show last lines and offer to restart - await handleDeadSession(session, autoRestart, geometryNeutral); + await handleDeadSession(session, autoRestart, modes); } async function requireGeometryNeutralAttach(name: string): Promise { @@ -1594,7 +1604,7 @@ async function requireGeometryNeutralAttach(name: string): Promise { async function handleDeadSession( session: SessionInfo, autoRestart = false, - geometryNeutral = false, + modes: AttachModes = {}, ): Promise { const meta = session.metadata; if (!meta) { @@ -1636,14 +1646,15 @@ async function handleDeadSession( scrubEnv: RESTART_SCRUBBED_ENV, }); console.log(`Session "${session.name}" restarted.`); - if (geometryNeutral) await requireGeometryNeutralAttach(session.name); - doAttach(session.name, geometryNeutral); + if (modes.geometryNeutral) await requireGeometryNeutralAttach(session.name); + doAttach(session.name, modes); } -function doAttach(name: string, geometryNeutral = false): void { +function doAttach(name: string, modes: AttachModes = {}): void { attach({ name, - geometryNeutral, + geometryNeutral: modes.geometryNeutral === true, + forceResize: modes.forceResize === true, onDetach: () => process.exit(0), onExit: (code) => process.exit(code), }); diff --git a/src/client.ts b/src/client.ts index 8576a5a..3b0d81c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,6 +4,8 @@ import { MessageType, PacketReader, encodeAttach, + ATTACH_FLAG_GEOMETRY_NEUTRAL, + ATTACH_FLAG_FORCE_RESIZE, encodeData, encodeDetach, encodePeek, @@ -381,6 +383,9 @@ export interface AttachOptions { * attach-time redraw nudges. Callers must first verify the daemon advertises * `capabilities.geometryNeutralAttach` in stats. */ geometryNeutral?: boolean; + /** Ask for the attach-time redraw nudge even when attaching at the session's + * current size, where it is otherwise skipped. */ + forceResize?: boolean; onExit?: (code: number) => void; onDetach?: () => void; /** Attach over this ALREADY-CONNECTED socket instead of dialing the local @@ -507,7 +512,10 @@ export function attach(options: AttachOptions): void { enterRawMode(); const rows = (stdout as tty.WriteStream).rows ?? 24; const cols = (stdout as tty.WriteStream).columns ?? 80; - try { socket.write(encodeAttach(rows, cols, options.geometryNeutral === true)); } catch {} + const flags = + (options.geometryNeutral ? ATTACH_FLAG_GEOMETRY_NEUTRAL : 0) | + (options.forceResize ? ATTACH_FLAG_FORCE_RESIZE : 0); + try { socket.write(encodeAttach(rows, cols, flags)); } catch {} wireInput(); } diff --git a/src/completions.ts b/src/completions.ts index dc7ca0e..02d883b 100644 --- a/src/completions.ts +++ b/src/completions.ts @@ -85,6 +85,7 @@ const COMMANDS: readonly CommandSpec[] = [ flags: [ { name: "auto-restart", short: "r", desc: "Auto-restart if the session is exited" }, { name: "no-resize", desc: "Preserve shared PTY geometry" }, + { name: "force-resize", desc: "Redraw-nudge even at the current size" }, { name: "force", desc: "Attach even from inside another pty" }, { name: "remote", desc: "Attach a session on a fabric peer" }, ], diff --git a/src/protocol.ts b/src/protocol.ts index 5c74f74..0ac995f 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -55,18 +55,18 @@ export function encodeData(data: string): Buffer { * input, but does not participate in PTY size negotiation or redraw nudges. */ export const ATTACH_FLAG_GEOMETRY_NEUTRAL = 0x01; -export function encodeAttach( - rows: number, - cols: number, - geometryNeutral = false, -): Buffer { +/** Optional ATTACH flag: nudge the child into a full redraw even when this + * client attaches at the size the session already has. */ +export const ATTACH_FLAG_FORCE_RESIZE = 0x02; + +export function encodeAttach(rows: number, cols: number, flags = 0): Buffer { // Keep the legacy frame byte-for-byte identical. The optional flag byte is - // appended only when needed; older servers already ignore bytes after the - // first four size bytes. - const payload = Buffer.alloc(geometryNeutral ? 5 : 4); + // appended only when a flag is set; older servers already ignore bytes after + // the first four size bytes. + const payload = Buffer.alloc(flags === 0 ? 4 : 5); payload.writeUInt16BE(rows, 0); payload.writeUInt16BE(cols, 2); - if (geometryNeutral) payload.writeUInt8(ATTACH_FLAG_GEOMETRY_NEUTRAL, 4); + if (flags !== 0) payload.writeUInt8(flags, 4); return encodePacket(MessageType.ATTACH, payload); } diff --git a/src/server.ts b/src/server.ts index 8e5c38b..a80fabc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,6 +18,7 @@ import { decodeSize, decodeAttachFlags, ATTACH_FLAG_GEOMETRY_NEUTRAL, + ATTACH_FLAG_FORCE_RESIZE, } from "./protocol.ts"; import { getSocketPath, @@ -606,8 +607,14 @@ export class PtyServer { case MessageType.ATTACH: { if (packet.payload.length < 4) break; const size = decodeSize(packet.payload); + const attachFlags = decodeAttachFlags(packet.payload); client.geometryNeutral = - (decodeAttachFlags(packet.payload) & ATTACH_FLAG_GEOMETRY_NEUTRAL) !== 0; + (attachFlags & ATTACH_FLAG_GEOMETRY_NEUTRAL) !== 0; + const forceResize = (attachFlags & ATTACH_FLAG_FORCE_RESIZE) !== 0; + // Read before negotiateSize(): a smaller client shrinks the session + // to its own size, which would then look like it had matched. + const sizeMatched = + size.rows === this.terminal.rows && size.cols === this.terminal.cols; client.rows = size.rows; client.cols = size.cols; client.attachSeq = ++this.attachCounter; @@ -642,7 +649,14 @@ export class PtyServer { // overwrites any serialize artifacts on the client. // A geometry-neutral viewer accepts the serialized snapshot as // is: nudging would perturb the shared child it came to observe. - if (!client.geometryNeutral) this.nudgeRedraw(); + // + // Skipped too when the client attached at the size the session + // already has: the child is drawn for that geometry, so the + // nudge buys nothing and wakes an otherwise idle process every + // time someone connects. `--force-resize` opts back in for + // children whose serialize replay needs the redraw regardless. + const nudge = sizeMatched ? forceResize : true; + if (!client.geometryNeutral && nudge) this.nudgeRedraw(); } }; diff --git a/tests/integration.test.ts b/tests/integration.test.ts index 4f973c3..b7da9b5 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -10,6 +10,8 @@ import { PacketReader, Packet, encodeAttach, + ATTACH_FLAG_GEOMETRY_NEUTRAL, + ATTACH_FLAG_FORCE_RESIZE, encodeData, encodeDetach, encodePeek, @@ -350,7 +352,7 @@ describe("integration", () => { const embedded = await connect(name); const embeddedReader = new PacketReader(); - embedded.write(encodeAttach(24, 80, true)); + embedded.write(encodeAttach(24, 80, ATTACH_FLAG_GEOMETRY_NEUTRAL)); await waitForType(embedded, embeddedReader, MessageType.SCREEN); // Input remains duplex, but the embedded viewer's smaller geometry is inert. @@ -388,21 +390,53 @@ describe("integration", () => { const neutral = await connect(name); const neutralReader = new PacketReader(); - neutral.write(encodeAttach(40, 120, true)); + neutral.write(encodeAttach(40, 120, ATTACH_FLAG_GEOMETRY_NEUTRAL)); await waitForType(neutral, neutralReader, MessageType.SCREEN); await new Promise((resolve) => setTimeout(resolve, 150)); expect(fs.existsSync(marker)).toBe(false); - // The ordinary path remains unchanged and still nudges for redraw fidelity. + // An ordinary attach at the session's current size is equally quiet: the + // child is already drawn for that geometry. const ordinary = await connect(name); const ordinaryReader = new PacketReader(); ordinary.write(encodeAttach(40, 120)); await waitForType(ordinary, ordinaryReader, MessageType.SCREEN); await new Promise((resolve) => setTimeout(resolve, 150)); + expect(fs.existsSync(marker)).toBe(false); + + // --force-resize opts back in to the nudge at the current size. + const forced = await connect(name); + const forcedReader = new PacketReader(); + forced.write(encodeAttach(40, 120, ATTACH_FLAG_FORCE_RESIZE)); + await waitForType(forced, forcedReader, MessageType.SCREEN); + await new Promise((resolve) => setTimeout(resolve, 150)); expect(fs.readFileSync(marker, "utf8")).toContain("WINCH"); neutral.destroy(); ordinary.destroy(); + forced.destroy(); + }); + + it("still nudges when the attaching client's size differs", async () => { + const name = uniqueName(); + const marker = path.join(testCwd, `${name}-winch`); + const reporter = [ + "const fs = require('node:fs')", + `process.on('SIGWINCH', () => fs.appendFileSync(${JSON.stringify(marker)}, 'WINCH\\n'))`, + "console.log('READY')", + "setInterval(() => {}, 1000)", + ].join(";"); + await startServer(name, process.execPath, ["-e", reporter], { rows: 40, cols: 120 }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const smaller = await connect(name); + const reader = new PacketReader(); + smaller.write(encodeAttach(24, 80)); + await waitForType(smaller, reader, MessageType.SCREEN); + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(fs.readFileSync(marker, "utf8")).toContain("WINCH"); + + smaller.destroy(); }); it("cleans up socket and pid files on close", async () => { diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index e1be734..605ba4b 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -16,6 +16,7 @@ import { decodeSize, decodeAttachFlags, ATTACH_FLAG_GEOMETRY_NEUTRAL, + ATTACH_FLAG_FORCE_RESIZE, decodeExit, } from "../src/protocol.ts"; import { Buffer } from "node:buffer"; @@ -45,18 +46,30 @@ describe("protocol", () => { expect(size.cols).toBe(80); }); - it("keeps legacy ATTACH byte-identical and appends neutral geometry as a flag", () => { + it("keeps legacy ATTACH byte-identical and appends set flags as a flag byte", () => { const legacy = encodeAttach(24, 80); expect(legacy).toEqual( encodePacket(MessageType.ATTACH, Buffer.from([0, 24, 0, 80])), ); + // An explicitly empty flag set stays on the legacy 4-byte frame. + expect(encodeAttach(24, 80, 0)).toEqual(legacy); const reader = new PacketReader(); - const [neutral] = reader.feed(encodeAttach(24, 80, true)); + const [neutral] = reader.feed( + encodeAttach(24, 80, ATTACH_FLAG_GEOMETRY_NEUTRAL), + ); expect(neutral.payload).toEqual(Buffer.from([0, 24, 0, 80, 1])); expect(decodeSize(neutral.payload)).toEqual({ rows: 24, cols: 80 }); expect(decodeAttachFlags(neutral.payload) & ATTACH_FLAG_GEOMETRY_NEUTRAL).toBe(1); expect(decodeAttachFlags(Buffer.from([0, 24, 0, 80]))).toBe(0); + + const [both] = new PacketReader().feed( + encodeAttach(24, 80, ATTACH_FLAG_GEOMETRY_NEUTRAL | ATTACH_FLAG_FORCE_RESIZE), + ); + expect(both.payload).toEqual(Buffer.from([0, 24, 0, 80, 3])); + expect(decodeAttachFlags(both.payload) & ATTACH_FLAG_FORCE_RESIZE).toBe( + ATTACH_FLAG_FORCE_RESIZE, + ); }); it("round-trips a DETACH packet", () => { From 5560269d65591ae9b7bdc16250d443cc31c8eed9 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:03:57 +0200 Subject: [PATCH 2/3] refactor(attach): remove geometry-neutral attach (--no-resize) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pty attach --no-resize` (PR #96) let an interactive client receive the live screen and send input while opting out of min-wins size negotiation and the attach-time redraw SIGWINCH nudge. Its only consumer, fractal, is switching off it. The real problem fractal hit was the unconditional nudge on attach: every connect woke an idle child and perturbed the shared session. The parent commit fixes that directly — the nudge is now skipped when the client attaches at the size the session already has, with `--force-resize` to opt back in for children whose serialize replay needs the redraw regardless. That covers fractal's case without a second attach mode, so fractal now attaches ordinarily at the session's own geometry. Removes the ATTACH_FLAG_GEOMETRY_NEUTRAL protocol flag, the per-client `geometryNeutral` state and its stats counter, the `capabilities.geometryNeutralAttach` advertisement and the CLI's capability probe, and the `--no-resize` flag with its help and completion entries. ATTACH_FLAG_FORCE_RESIZE keeps its released value of 0x02; 0x01 is retired rather than reused. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Lw7FSfYArhebq26GUb1kHi --- CHANGELOG.md | 4 -- README.md | 1 - completions/pty.bash | 2 +- completions/pty.fish | 1 - completions/pty.zsh | 1 - src/cli.ts | 32 +++------------ src/client.ts | 13 +----- src/completions.ts | 1 - src/protocol.ts | 4 -- src/server.ts | 23 ++--------- tests/integration.test.ts | 50 ++--------------------- tests/neutral-attach-cli.test.ts | 69 -------------------------------- tests/protocol.test.ts | 19 +++------ 13 files changed, 21 insertions(+), 199 deletions(-) delete mode 100644 tests/neutral-attach-cli.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 77a8bd8..0845e29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,10 +49,6 @@ - New `badge(label, opts)` widget renders a small SRCL-style chip: an uppercase, padded label on a muted fill (mirroring SRCL's `Badge` — `text-transform: uppercase`, `padding: 0 1ch`, filled background). Status `variant`s (`neutral | ok | warn | error | accent | info`) color the label, or fill the chip when `solid: true`. `uppercase` and `bold` are configurable. Returns a `TextNode` styled with semantic color tokens only (no terminal-only escapes), so the same call can render under a non-terminal backend. -### Geometry-neutral interactive attach - -- `pty attach --no-resize ` receives the live screen and forwards input without contributing its terminal dimensions to the shared PTY's min-wins size negotiation. It also suppresses the attach-time redraw `SIGWINCH` nudge. This supports embedded interactive viewers whose stdio is piped (and would otherwise report the 80x24 fallback) while leaving ordinary multi-viewer behavior unchanged. The CLI refuses the flag against a daemon that does not advertise `capabilities.geometryNeutralAttach`, rather than silently degrading. - ### `pty.toml` — per-session `cwd` field - Sessions in a `pty.toml` accept an optional `cwd = "..."` setting the working directory. An absolute path is used as-is; a relative path resolves against the manifest's directory. Omitted → the session runs in the manifest's directory (the unchanged default). diff --git a/README.md b/README.md index 76ec1bf..a12744b 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,6 @@ pty list --filter-tag role=web # show only sessions with matching tag pty attach myserver # reconnect to a session pty attach -r myserver # reconnect, auto-restart if exited -pty attach --no-resize myserver # interactive viewer; preserve shared PTY geometry pty attach --force-resize myserver # nudge a redraw even at the current size pty exec -- codex # replace this session's process (inside a session) pty peek myserver # print current screen and exit diff --git a/completions/pty.bash b/completions/pty.bash index 1c2f214..ac38e3c 100644 --- a/completions/pty.bash +++ b/completions/pty.bash @@ -28,7 +28,7 @@ _pty() { ;; attach|a) if [[ "${cur}" == -* ]]; then - COMPREPLY=($(compgen -W "-r --auto-restart --no-resize --force-resize --force --remote" -- "${cur}")) + COMPREPLY=($(compgen -W "-r --auto-restart --force-resize --force --remote" -- "${cur}")) else COMPREPLY=($(compgen -W "${names}" -- "${cur}")) fi diff --git a/completions/pty.fish b/completions/pty.fish index 12dfcfe..f1c0dc4 100644 --- a/completions/pty.fish +++ b/completions/pty.fish @@ -75,7 +75,6 @@ complete -c pty -n '__pty_using_command run' -l cwd -d 'Working directory' complete -c pty -n '__pty_using_command run' -l isolate-env -d 'Scrub env to a safe allow-list' complete -c pty -n '__pty_using_command run' -l force -d 'Create even from inside another pty' complete -c pty -n '__pty_using_command attach a' -l auto-restart -s r -d 'Auto-restart if the session is exited' -complete -c pty -n '__pty_using_command attach a' -l no-resize -d 'Preserve shared PTY geometry' complete -c pty -n '__pty_using_command attach a' -l force-resize -d 'Redraw-nudge even at the current size' complete -c pty -n '__pty_using_command attach a' -l force -d 'Attach even from inside another pty' complete -c pty -n '__pty_using_command attach a' -l remote -d 'Attach a session on a fabric peer' diff --git a/completions/pty.zsh b/completions/pty.zsh index 2d06634..7260ff6 100644 --- a/completions/pty.zsh +++ b/completions/pty.zsh @@ -68,7 +68,6 @@ _pty() { attach|a) _arguments \ '(r --auto-restart){r,--auto-restart}[Auto-restart if the session is exited]' \ - '--no-resize[Preserve shared PTY geometry]' \ '--force-resize[Redraw-nudge even at the current size]' \ '--force[Attach even from inside another pty]' \ '--remote[Attach a session on a fabric peer]' \ diff --git a/src/cli.ts b/src/cli.ts index 35e51b3..d9e49e5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -99,13 +99,12 @@ Examples: pty run -- node server.js pty run -d --name "API" --tag role=web -- node server.js`, - attach: `Usage: pty attach [-r] [--no-resize] [--force-resize] [--force] [--remote ] + attach: `Usage: pty attach [-r] [--force-resize] [--force] [--remote ] Reconnect to a session (alias: pty a). Detach again with Ctrl+\\. Flags: -r, --auto-restart Auto-restart the session if it has exited - --no-resize Send input and receive output without changing PTY geometry --force-resize Nudge the child into a redraw even when attaching at its current size (that nudge is skipped by default) --force Attach even from inside another pty session (nested) @@ -403,7 +402,6 @@ Create sessions: Attach & interact: pty attach Attach to an existing session (alias: pty a) - pty attach --no-resize Attach without changing shared PTY geometry pty attach --force Attach even from inside another pty session (nested) pty attach -r Attach, auto-restart if the session is exited pty attach --remote Attach a session on a fabric peer (over fabric) @@ -857,14 +855,12 @@ async function main(): Promise { case "a": { let autoRestart = false; let force = false; - let geometryNeutral = false; let forceResize = false; let attachName: string | null = null; let attachRemotePeer: 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 === "--no-resize") geometryNeutral = true; else if (a === "--force-resize") forceResize = true; else if (a === "--force") force = true; else if (a === "--remote" && ai + 1 < args.length) { attachRemotePeer = args[++ai]; } @@ -875,7 +871,7 @@ async function main(): Promise { } } if (!attachName) { - console.error("Usage: pty attach [-r|--auto-restart] [--no-resize] [--force-resize] [--force] [--remote ] "); + console.error("Usage: pty attach [-r|--auto-restart] [--force-resize] [--force] [--remote ] "); process.exit(1); } // Nesting guard runs BEFORE name validation / ref resolution. A nested @@ -891,15 +887,15 @@ async function main(): Promise { " Pass --force to attach anyway (nested clients are usually a mistake).", }); if (attachRemotePeer) { - if (geometryNeutral || forceResize) { - console.error("pty attach: --no-resize/--force-resize are not yet supported with --remote"); + if (forceResize) { + console.error("pty attach: --force-resize is not yet supported with --remote"); process.exit(1); } // The name is the session's id ON THE REMOTE — don't resolve locally. await cmdAttachRemote(attachRemotePeer, attachName); } else { const resolvedAttachName = await resolveRef(attachName); - await cmdAttach(resolvedAttachName, autoRestart, force, { geometryNeutral, forceResize }); + await cmdAttach(resolvedAttachName, autoRestart, force, { forceResize }); } break; } @@ -1555,7 +1551,6 @@ async function cmdRun( /** Per-attach client behaviour that the daemon reads off the ATTACH frame. */ interface AttachModes { - geometryNeutral?: boolean; forceResize?: boolean; } @@ -1578,7 +1573,6 @@ async function cmdAttach( } if (session.status === "running") { - if (modes.geometryNeutral) await requireGeometryNeutralAttach(name); doAttach(name, modes); return; } @@ -1587,20 +1581,6 @@ async function cmdAttach( await handleDeadSession(session, autoRestart, modes); } -async function requireGeometryNeutralAttach(name: string): Promise { - let supported = false; - try { - const stats = await queryStats(name); - supported = stats.capabilities?.geometryNeutralAttach === true; - } catch {} - if (!supported) { - console.error( - `Session "${name}" does not support --no-resize. Upgrade its pty daemon or attach without the flag.`, - ); - process.exit(1); - } -} - async function handleDeadSession( session: SessionInfo, autoRestart = false, @@ -1646,14 +1626,12 @@ async function handleDeadSession( scrubEnv: RESTART_SCRUBBED_ENV, }); console.log(`Session "${session.name}" restarted.`); - if (modes.geometryNeutral) await requireGeometryNeutralAttach(session.name); doAttach(session.name, modes); } function doAttach(name: string, modes: AttachModes = {}): void { attach({ name, - geometryNeutral: modes.geometryNeutral === true, forceResize: modes.forceResize === true, onDetach: () => process.exit(0), onExit: (code) => process.exit(code), diff --git a/src/client.ts b/src/client.ts index 3b0d81c..22c1e67 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,7 +4,6 @@ import { MessageType, PacketReader, encodeAttach, - ATTACH_FLAG_GEOMETRY_NEUTRAL, ATTACH_FLAG_FORCE_RESIZE, encodeData, encodeDetach, @@ -316,9 +315,7 @@ export interface StatsResult { total: number; attached: number; readOnly: number; - geometryNeutral?: number; }; - capabilities?: { geometryNeutralAttach?: boolean }; modes: { sgrMouse: boolean; cursorHidden: boolean; @@ -379,10 +376,6 @@ export function queryStats(name: string, timeoutMs = 2000): Promise export interface AttachOptions { name: string; - /** Receive output and send input without participating in PTY geometry or - * attach-time redraw nudges. Callers must first verify the daemon advertises - * `capabilities.geometryNeutralAttach` in stats. */ - geometryNeutral?: boolean; /** Ask for the attach-time redraw nudge even when attaching at the session's * current size, where it is otherwise skipped. */ forceResize?: boolean; @@ -502,7 +495,7 @@ export function attach(options: AttachOptions): void { stdin.on("data", stdinDataHandler); // Explicitly resume stdin — see the note on readline leaving flowing=false. stdin.resume(); - if (stdout instanceof tty.WriteStream && !options.geometryNeutral) { + if (stdout instanceof tty.WriteStream) { resizeHandler = () => { try { socket.write(encodeResize(stdout.rows, stdout.columns)); } catch {} }; stdout.on("resize", resizeHandler); } @@ -512,9 +505,7 @@ export function attach(options: AttachOptions): void { enterRawMode(); const rows = (stdout as tty.WriteStream).rows ?? 24; const cols = (stdout as tty.WriteStream).columns ?? 80; - const flags = - (options.geometryNeutral ? ATTACH_FLAG_GEOMETRY_NEUTRAL : 0) | - (options.forceResize ? ATTACH_FLAG_FORCE_RESIZE : 0); + const flags = options.forceResize ? ATTACH_FLAG_FORCE_RESIZE : 0; try { socket.write(encodeAttach(rows, cols, flags)); } catch {} wireInput(); } diff --git a/src/completions.ts b/src/completions.ts index 02d883b..18b9530 100644 --- a/src/completions.ts +++ b/src/completions.ts @@ -84,7 +84,6 @@ const COMMANDS: readonly CommandSpec[] = [ dynamic: "sessions", flags: [ { name: "auto-restart", short: "r", desc: "Auto-restart if the session is exited" }, - { name: "no-resize", desc: "Preserve shared PTY geometry" }, { name: "force-resize", desc: "Redraw-nudge even at the current size" }, { name: "force", desc: "Attach even from inside another pty" }, { name: "remote", desc: "Attach a session on a fabric peer" }, diff --git a/src/protocol.ts b/src/protocol.ts index 0ac995f..5f3d1e3 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -51,10 +51,6 @@ export function encodeData(data: string): Buffer { return encodePacket(MessageType.DATA, Buffer.from(data)); } -/** Optional ATTACH flag: this interactive client receives output and may send - * input, but does not participate in PTY size negotiation or redraw nudges. */ -export const ATTACH_FLAG_GEOMETRY_NEUTRAL = 0x01; - /** Optional ATTACH flag: nudge the child into a full redraw even when this * client attaches at the size the session already has. */ export const ATTACH_FLAG_FORCE_RESIZE = 0x02; diff --git a/src/server.ts b/src/server.ts index a80fabc..7050f3e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -17,7 +17,6 @@ import { encodeStatusResponse, decodeSize, decodeAttachFlags, - ATTACH_FLAG_GEOMETRY_NEUTRAL, ATTACH_FLAG_FORCE_RESIZE, } from "./protocol.ts"; import { @@ -40,7 +39,6 @@ interface Client { cols: number; readonly: boolean; attachSeq: number; - geometryNeutral: boolean; } export interface ServerOptions { @@ -587,7 +585,6 @@ export class PtyServer { cols: this.terminal.cols, readonly: false, attachSeq: 0, - geometryNeutral: false, }; this.clients.set(socket, client); @@ -608,8 +605,6 @@ export class PtyServer { if (packet.payload.length < 4) break; const size = decodeSize(packet.payload); const attachFlags = decodeAttachFlags(packet.payload); - client.geometryNeutral = - (attachFlags & ATTACH_FLAG_GEOMETRY_NEUTRAL) !== 0; const forceResize = (attachFlags & ATTACH_FLAG_FORCE_RESIZE) !== 0; // Read before negotiateSize(): a smaller client shrinks the session // to its own size, which would then look like it had matched. @@ -647,16 +642,14 @@ export class PtyServer { // drew (e.g., background fills in ratatui). Nudge the child // with a SIGWINCH so it does a fresh full redraw, whose DATA // overwrites any serialize artifacts on the client. - // A geometry-neutral viewer accepts the serialized snapshot as - // is: nudging would perturb the shared child it came to observe. // - // Skipped too when the client attached at the size the session + // Skipped when the client attached at the size the session // already has: the child is drawn for that geometry, so the // nudge buys nothing and wakes an otherwise idle process every // time someone connects. `--force-resize` opts back in for // children whose serialize replay needs the redraw regardless. const nudge = sizeMatched ? forceResize : true; - if (!client.geometryNeutral && nudge) this.nudgeRedraw(); + if (nudge) this.nudgeRedraw(); } }; @@ -768,13 +761,9 @@ export class PtyServer { let attached = 0; let readOnly = 0; - let geometryNeutral = 0; for (const c of this.clients.values()) { if (c.readonly) readOnly++; - else if (c.attachSeq > 0) { - attached++; - if (c.geometryNeutral) geometryNeutral++; - } + else if (c.attachSeq > 0) attached++; } const createdAt = meta?.createdAt ?? null; @@ -809,10 +798,6 @@ export class PtyServer { total: attached + readOnly, attached, readOnly, - geometryNeutral, - }, - capabilities: { - geometryNeutralAttach: true, }, modes: { sgrMouse: this.sgrMouseMode, @@ -832,7 +817,7 @@ export class PtyServer { let cols = 0; for (const client of this.clients.values()) { - if (!client.readonly && client.attachSeq > 0 && !client.geometryNeutral) { + if (!client.readonly && client.attachSeq > 0) { rows = rows === 0 ? client.rows : Math.min(rows, client.rows); cols = cols === 0 ? client.cols : Math.min(cols, client.cols); } diff --git a/tests/integration.test.ts b/tests/integration.test.ts index b7da9b5..cf6faec 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -10,7 +10,6 @@ import { PacketReader, Packet, encodeAttach, - ATTACH_FLAG_GEOMETRY_NEUTRAL, ATTACH_FLAG_FORCE_RESIZE, encodeData, encodeDetach, @@ -341,42 +340,7 @@ describe("integration", () => { client2.destroy(); }); - it("geometry-neutral client sends input without changing the shared PTY size", async () => { - const name = uniqueName(); - await startServer(name, "sh", [], { rows: 24, cols: 80 }); - - const primary = await connect(name); - const primaryReader = new PacketReader(); - primary.write(encodeAttach(50, 200)); - await waitForType(primary, primaryReader, MessageType.SCREEN); - - const embedded = await connect(name); - const embeddedReader = new PacketReader(); - embedded.write(encodeAttach(24, 80, ATTACH_FLAG_GEOMETRY_NEUTRAL)); - await waitForType(embedded, embeddedReader, MessageType.SCREEN); - - // Input remains duplex, but the embedded viewer's smaller geometry is inert. - embedded.write(encodeData("stty size\n")); - await waitForContent(embedded, embeddedReader, "50 200"); - - // Server-side enforcement: even a neutral client that sends RESIZE cannot - // move the shared child. - embedded.write(encodeResize(10, 40)); - await new Promise((resolve) => setTimeout(resolve, 100)); - embedded.write(encodeData("stty size\n")); - await waitForContent(embedded, embeddedReader, "50 200"); - - primary.write(encodeStatus()); - const status = await waitForType(primary, primaryReader, MessageType.STATUS); - const stats = JSON.parse(status.payload.toString()); - expect(stats.capabilities.geometryNeutralAttach).toBe(true); - expect(stats.clients.geometryNeutral).toBe(1); - - primary.destroy(); - embedded.destroy(); - }); - - it("geometry-neutral attach suppresses the redraw SIGWINCH nudge", async () => { + it("skips the redraw SIGWINCH nudge at the session's current size unless --force-resize", async () => { const name = uniqueName(); const marker = path.join(testCwd, `${name}-winch`); const reporter = [ @@ -388,15 +352,8 @@ describe("integration", () => { await startServer(name, process.execPath, ["-e", reporter], { rows: 40, cols: 120 }); await new Promise((resolve) => setTimeout(resolve, 100)); - const neutral = await connect(name); - const neutralReader = new PacketReader(); - neutral.write(encodeAttach(40, 120, ATTACH_FLAG_GEOMETRY_NEUTRAL)); - await waitForType(neutral, neutralReader, MessageType.SCREEN); - await new Promise((resolve) => setTimeout(resolve, 150)); - expect(fs.existsSync(marker)).toBe(false); - - // An ordinary attach at the session's current size is equally quiet: the - // child is already drawn for that geometry. + // An ordinary attach at the session's current size is quiet: the child is + // already drawn for that geometry. const ordinary = await connect(name); const ordinaryReader = new PacketReader(); ordinary.write(encodeAttach(40, 120)); @@ -412,7 +369,6 @@ describe("integration", () => { await new Promise((resolve) => setTimeout(resolve, 150)); expect(fs.readFileSync(marker, "utf8")).toContain("WINCH"); - neutral.destroy(); ordinary.destroy(); forced.destroy(); }); diff --git a/tests/neutral-attach-cli.test.ts b/tests/neutral-attach-cli.test.ts deleted file mode 100644 index 1473f8b..0000000 --- a/tests/neutral-attach-cli.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { afterAll, describe, expect, it } from "vitest"; -import * as fs from "node:fs"; -import * as net from "node:net"; -import * as os from "node:os"; -import * as path from "node:path"; -import { spawn } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { - MessageType, - PacketReader, - encodeStatusResponse, -} from "../src/protocol.ts"; - -const dirname = path.dirname(fileURLToPath(import.meta.url)); -const cliPath = path.join(dirname, "..", "dist", "cli.js"); -const root = fs.mkdtempSync(path.join(os.tmpdir(), "pty-neutral-cli-")); - -afterAll(() => fs.rmSync(root, { recursive: true, force: true })); - -describe("pty attach --no-resize", () => { - it("refuses an old daemon before sending ATTACH", async () => { - const name = "old-daemon"; - const socketPath = path.join(root, `${name}.sock`); - fs.writeFileSync(path.join(root, `${name}.pid`), `${process.pid}\n`); - - const received: number[] = []; - const server = net.createServer((socket) => { - const reader = new PacketReader(); - socket.on("data", (data) => { - for (const packet of reader.feed(Buffer.from(data))) { - received.push(packet.type); - if (packet.type === MessageType.STATUS) { - // Pre-capability daemon: valid stats, but no capabilities block. - socket.write(encodeStatusResponse(JSON.stringify({ name }))); - } - } - }); - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(socketPath, resolve); - }); - - const env: NodeJS.ProcessEnv = { ...process.env, PTY_ROOT: root }; - delete env.PTY_SESSION; - const child = spawn(process.execPath, [cliPath, "attach", "--no-resize", name], { - env, - stdio: ["ignore", "pipe", "pipe"], - }); - let stderr = ""; - child.stderr.on("data", (data: Buffer) => { stderr += data.toString(); }); - const code = await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - child.kill("SIGKILL"); - reject(new Error("CLI did not reject the old daemon")); - }, 5000); - child.once("exit", (value) => { - clearTimeout(timer); - resolve(value); - }); - }); - await new Promise((resolve) => server.close(() => resolve())); - - expect(code).not.toBe(0); - expect(stderr).toContain("does not support --no-resize"); - expect(received).toContain(MessageType.STATUS); - expect(received).not.toContain(MessageType.ATTACH); - }); -}); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 605ba4b..653c89a 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -15,7 +15,6 @@ import { encodeStatusResponse, decodeSize, decodeAttachFlags, - ATTACH_FLAG_GEOMETRY_NEUTRAL, ATTACH_FLAG_FORCE_RESIZE, decodeExit, } from "../src/protocol.ts"; @@ -55,21 +54,15 @@ describe("protocol", () => { expect(encodeAttach(24, 80, 0)).toEqual(legacy); const reader = new PacketReader(); - const [neutral] = reader.feed( - encodeAttach(24, 80, ATTACH_FLAG_GEOMETRY_NEUTRAL), + const [forced] = reader.feed( + encodeAttach(24, 80, ATTACH_FLAG_FORCE_RESIZE), ); - expect(neutral.payload).toEqual(Buffer.from([0, 24, 0, 80, 1])); - expect(decodeSize(neutral.payload)).toEqual({ rows: 24, cols: 80 }); - expect(decodeAttachFlags(neutral.payload) & ATTACH_FLAG_GEOMETRY_NEUTRAL).toBe(1); - expect(decodeAttachFlags(Buffer.from([0, 24, 0, 80]))).toBe(0); - - const [both] = new PacketReader().feed( - encodeAttach(24, 80, ATTACH_FLAG_GEOMETRY_NEUTRAL | ATTACH_FLAG_FORCE_RESIZE), - ); - expect(both.payload).toEqual(Buffer.from([0, 24, 0, 80, 3])); - expect(decodeAttachFlags(both.payload) & ATTACH_FLAG_FORCE_RESIZE).toBe( + expect(forced.payload).toEqual(Buffer.from([0, 24, 0, 80, 2])); + expect(decodeSize(forced.payload)).toEqual({ rows: 24, cols: 80 }); + expect(decodeAttachFlags(forced.payload) & ATTACH_FLAG_FORCE_RESIZE).toBe( ATTACH_FLAG_FORCE_RESIZE, ); + expect(decodeAttachFlags(Buffer.from([0, 24, 0, 80]))).toBe(0); }); it("round-trips a DETACH packet", () => { From 659beb4f531a911a102f637df4932b8c06a220f0 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:30:29 +0200 Subject: [PATCH 3/3] refactor(attach): drop --force-resize and the ATTACH flag byte `--force-resize` was added alongside the conditional attach-time redraw nudge as an escape hatch, but nothing ever asked for it: no caller, no consumer, no reported child whose serialize replay actually needed the unconditional nudge. It was speculative surface, and it dragged an entire protocol mechanism along with it. Removing it lets the ATTACH frame go back to a plain 4-byte payload -- no optional flag byte, no `decodeAttachFlags`, no flag constants, no `AttachModes` plumbing through `cmdAttach`/`handleDeadSession`/ `doAttach`. `encodeAttach(rows, cols)` is once again exactly what it looks like. What remains is the branch's whole net effect: one behaviour fix. The attach-time redraw nudge fires only when the attaching client's geometry differs from the session's, so connecting a viewer at the current size no longer delivers SIGWINCH to an idle child. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Lw7FSfYArhebq26GUb1kHi --- CHANGELOG.md | 2 +- README.md | 1 - completions/pty.bash | 2 +- completions/pty.fish | 1 - completions/pty.zsh | 1 - src/cli.ts | 30 +++++++----------------------- src/client.ts | 7 +------ src/completions.ts | 1 - src/protocol.ts | 16 ++-------------- src/server.ts | 10 ++-------- tests/integration.test.ts | 12 +----------- tests/protocol.test.ts | 22 ++++++---------------- 12 files changed, 21 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0845e29..f66050b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Attach no longer nudges a child that is already at the right size -- The attach-time redraw nudge (resize to `cols - 1` and back, to paper over serialize-replay artifacts) now fires only when the attaching client's geometry differs from the session's. Attaching at the size the session already has leaves the child alone, so connecting a viewer no longer delivers `SIGWINCH` to an idle process. `pty attach --force-resize` restores the nudge unconditionally for children whose replay needs it. +- The attach-time redraw nudge (resize to `cols - 1` and back, to paper over serialize-replay artifacts) now fires only when the attaching client's geometry differs from the session's. Attaching at the size the session already has leaves the child alone, so connecting a viewer no longer delivers `SIGWINCH` to an idle process. ### Package renamed: `@myobie/pty` → `@compoundingtech/pty` diff --git a/README.md b/README.md index a12744b..5cf2620 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,6 @@ pty list --filter-tag role=web # show only sessions with matching tag pty attach myserver # reconnect to a session pty attach -r myserver # reconnect, auto-restart if exited -pty attach --force-resize myserver # nudge a redraw even at the current size pty exec -- codex # replace this session's process (inside a session) pty peek myserver # print current screen and exit pty peek --plain myserver # print as plain text (no ANSI) diff --git a/completions/pty.bash b/completions/pty.bash index ac38e3c..f6a2c71 100644 --- a/completions/pty.bash +++ b/completions/pty.bash @@ -28,7 +28,7 @@ _pty() { ;; attach|a) if [[ "${cur}" == -* ]]; then - COMPREPLY=($(compgen -W "-r --auto-restart --force-resize --force --remote" -- "${cur}")) + COMPREPLY=($(compgen -W "-r --auto-restart --force --remote" -- "${cur}")) else COMPREPLY=($(compgen -W "${names}" -- "${cur}")) fi diff --git a/completions/pty.fish b/completions/pty.fish index f1c0dc4..2ac2716 100644 --- a/completions/pty.fish +++ b/completions/pty.fish @@ -75,7 +75,6 @@ complete -c pty -n '__pty_using_command run' -l cwd -d 'Working directory' complete -c pty -n '__pty_using_command run' -l isolate-env -d 'Scrub env to a safe allow-list' complete -c pty -n '__pty_using_command run' -l force -d 'Create even from inside another pty' complete -c pty -n '__pty_using_command attach a' -l auto-restart -s r -d 'Auto-restart if the session is exited' -complete -c pty -n '__pty_using_command attach a' -l force-resize -d 'Redraw-nudge even at the current size' complete -c pty -n '__pty_using_command attach a' -l force -d 'Attach even from inside another pty' complete -c pty -n '__pty_using_command attach a' -l remote -d 'Attach a session on a fabric peer' complete -c pty -n '__pty_using_command attach a' -a '(__pty_sessions)' -d 'Session' diff --git a/completions/pty.zsh b/completions/pty.zsh index 7260ff6..3c7afa7 100644 --- a/completions/pty.zsh +++ b/completions/pty.zsh @@ -68,7 +68,6 @@ _pty() { attach|a) _arguments \ '(r --auto-restart){r,--auto-restart}[Auto-restart if the session is exited]' \ - '--force-resize[Redraw-nudge even at the current size]' \ '--force[Attach even from inside another pty]' \ '--remote[Attach a session on a fabric peer]' \ '1:session:_pty_sessions' diff --git a/src/cli.ts b/src/cli.ts index d9e49e5..82b62f5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -99,14 +99,12 @@ Examples: pty run -- node server.js pty run -d --name "API" --tag role=web -- node server.js`, - attach: `Usage: pty attach [-r] [--force-resize] [--force] [--remote ] + attach: `Usage: pty attach [-r] [--force] [--remote ] Reconnect to a session (alias: pty a). Detach again with Ctrl+\\. Flags: -r, --auto-restart Auto-restart the session if it has exited - --force-resize Nudge the child into a redraw even when attaching at its - current size (that nudge is skipped by default) --force Attach even from inside another pty session (nested) --remote Attach a session on a fabric peer (over fabric); is the session's name/id ON THE REMOTE @@ -855,13 +853,11 @@ async function main(): Promise { case "a": { let autoRestart = false; let force = false; - let forceResize = false; let attachName: string | null = null; let attachRemotePeer: 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-resize") forceResize = true; else if (a === "--force") force = true; else if (a === "--remote" && ai + 1 < args.length) { attachRemotePeer = args[++ai]; } else if (!attachName) attachName = a; @@ -871,7 +867,7 @@ async function main(): Promise { } } if (!attachName) { - console.error("Usage: pty attach [-r|--auto-restart] [--force-resize] [--force] [--remote ] "); + console.error("Usage: pty attach [-r|--auto-restart] [--force] [--remote ] "); process.exit(1); } // Nesting guard runs BEFORE name validation / ref resolution. A nested @@ -887,15 +883,11 @@ async function main(): Promise { " Pass --force to attach anyway (nested clients are usually a mistake).", }); if (attachRemotePeer) { - if (forceResize) { - console.error("pty attach: --force-resize is not yet supported with --remote"); - process.exit(1); - } // The name is the session's id ON THE REMOTE — don't resolve locally. await cmdAttachRemote(attachRemotePeer, attachName); } else { const resolvedAttachName = await resolveRef(attachName); - await cmdAttach(resolvedAttachName, autoRestart, force, { forceResize }); + await cmdAttach(resolvedAttachName, autoRestart, force); } break; } @@ -1549,16 +1541,10 @@ async function cmdRun( doAttach(name); } -/** Per-attach client behaviour that the daemon reads off the ATTACH frame. */ -interface AttachModes { - forceResize?: boolean; -} - async function cmdAttach( name: string, autoRestart = false, _force = false, - modes: AttachModes = {}, ): Promise { // Nesting guard runs in the dispatcher (before name resolution) so the // user gets the nesting hint even for typo'd refs. cmdAttach itself is @@ -1573,18 +1559,17 @@ async function cmdAttach( } if (session.status === "running") { - doAttach(name, modes); + doAttach(name); return; } // Dead session — show last lines and offer to restart - await handleDeadSession(session, autoRestart, modes); + await handleDeadSession(session, autoRestart); } async function handleDeadSession( session: SessionInfo, autoRestart = false, - modes: AttachModes = {}, ): Promise { const meta = session.metadata; if (!meta) { @@ -1626,13 +1611,12 @@ async function handleDeadSession( scrubEnv: RESTART_SCRUBBED_ENV, }); console.log(`Session "${session.name}" restarted.`); - doAttach(session.name, modes); + doAttach(session.name); } -function doAttach(name: string, modes: AttachModes = {}): void { +function doAttach(name: string): void { attach({ name, - forceResize: modes.forceResize === true, onDetach: () => process.exit(0), onExit: (code) => process.exit(code), }); diff --git a/src/client.ts b/src/client.ts index 22c1e67..00acafa 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,7 +4,6 @@ import { MessageType, PacketReader, encodeAttach, - ATTACH_FLAG_FORCE_RESIZE, encodeData, encodeDetach, encodePeek, @@ -376,9 +375,6 @@ export function queryStats(name: string, timeoutMs = 2000): Promise export interface AttachOptions { name: string; - /** Ask for the attach-time redraw nudge even when attaching at the session's - * current size, where it is otherwise skipped. */ - forceResize?: boolean; onExit?: (code: number) => void; onDetach?: () => void; /** Attach over this ALREADY-CONNECTED socket instead of dialing the local @@ -505,8 +501,7 @@ export function attach(options: AttachOptions): void { enterRawMode(); const rows = (stdout as tty.WriteStream).rows ?? 24; const cols = (stdout as tty.WriteStream).columns ?? 80; - const flags = options.forceResize ? ATTACH_FLAG_FORCE_RESIZE : 0; - try { socket.write(encodeAttach(rows, cols, flags)); } catch {} + try { socket.write(encodeAttach(rows, cols)); } catch {} wireInput(); } diff --git a/src/completions.ts b/src/completions.ts index 18b9530..2cfcc3f 100644 --- a/src/completions.ts +++ b/src/completions.ts @@ -84,7 +84,6 @@ const COMMANDS: readonly CommandSpec[] = [ dynamic: "sessions", flags: [ { name: "auto-restart", short: "r", desc: "Auto-restart if the session is exited" }, - { name: "force-resize", desc: "Redraw-nudge even at the current size" }, { name: "force", desc: "Attach even from inside another pty" }, { name: "remote", desc: "Attach a session on a fabric peer" }, ], diff --git a/src/protocol.ts b/src/protocol.ts index 5f3d1e3..a04a6fd 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -51,25 +51,13 @@ export function encodeData(data: string): Buffer { return encodePacket(MessageType.DATA, Buffer.from(data)); } -/** Optional ATTACH flag: nudge the child into a full redraw even when this - * client attaches at the size the session already has. */ -export const ATTACH_FLAG_FORCE_RESIZE = 0x02; - -export function encodeAttach(rows: number, cols: number, flags = 0): Buffer { - // Keep the legacy frame byte-for-byte identical. The optional flag byte is - // appended only when a flag is set; older servers already ignore bytes after - // the first four size bytes. - const payload = Buffer.alloc(flags === 0 ? 4 : 5); +export function encodeAttach(rows: number, cols: number): Buffer { + const payload = Buffer.alloc(4); payload.writeUInt16BE(rows, 0); payload.writeUInt16BE(cols, 2); - if (flags !== 0) payload.writeUInt8(flags, 4); return encodePacket(MessageType.ATTACH, payload); } -export function decodeAttachFlags(payload: Buffer): number { - return payload.length >= 5 ? payload.readUInt8(4) : 0; -} - export function encodeDetach(): Buffer { return encodePacket(MessageType.DETACH, Buffer.alloc(0)); } diff --git a/src/server.ts b/src/server.ts index 7050f3e..4442bb9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -16,8 +16,6 @@ import { encodeScreen, encodeStatusResponse, decodeSize, - decodeAttachFlags, - ATTACH_FLAG_FORCE_RESIZE, } from "./protocol.ts"; import { getSocketPath, @@ -604,8 +602,6 @@ export class PtyServer { case MessageType.ATTACH: { if (packet.payload.length < 4) break; const size = decodeSize(packet.payload); - const attachFlags = decodeAttachFlags(packet.payload); - const forceResize = (attachFlags & ATTACH_FLAG_FORCE_RESIZE) !== 0; // Read before negotiateSize(): a smaller client shrinks the session // to its own size, which would then look like it had matched. const sizeMatched = @@ -646,10 +642,8 @@ export class PtyServer { // Skipped when the client attached at the size the session // already has: the child is drawn for that geometry, so the // nudge buys nothing and wakes an otherwise idle process every - // time someone connects. `--force-resize` opts back in for - // children whose serialize replay needs the redraw regardless. - const nudge = sizeMatched ? forceResize : true; - if (nudge) this.nudgeRedraw(); + // time someone connects. + if (!sizeMatched) this.nudgeRedraw(); } }; diff --git a/tests/integration.test.ts b/tests/integration.test.ts index cf6faec..379f5c2 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -10,7 +10,6 @@ import { PacketReader, Packet, encodeAttach, - ATTACH_FLAG_FORCE_RESIZE, encodeData, encodeDetach, encodePeek, @@ -340,7 +339,7 @@ describe("integration", () => { client2.destroy(); }); - it("skips the redraw SIGWINCH nudge at the session's current size unless --force-resize", async () => { + it("skips the redraw SIGWINCH nudge at the session's current size", async () => { const name = uniqueName(); const marker = path.join(testCwd, `${name}-winch`); const reporter = [ @@ -361,16 +360,7 @@ describe("integration", () => { await new Promise((resolve) => setTimeout(resolve, 150)); expect(fs.existsSync(marker)).toBe(false); - // --force-resize opts back in to the nudge at the current size. - const forced = await connect(name); - const forcedReader = new PacketReader(); - forced.write(encodeAttach(40, 120, ATTACH_FLAG_FORCE_RESIZE)); - await waitForType(forced, forcedReader, MessageType.SCREEN); - await new Promise((resolve) => setTimeout(resolve, 150)); - expect(fs.readFileSync(marker, "utf8")).toContain("WINCH"); - ordinary.destroy(); - forced.destroy(); }); it("still nudges when the attaching client's size differs", async () => { diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 653c89a..56db597 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -14,8 +14,6 @@ import { encodeStatus, encodeStatusResponse, decodeSize, - decodeAttachFlags, - ATTACH_FLAG_FORCE_RESIZE, decodeExit, } from "../src/protocol.ts"; import { Buffer } from "node:buffer"; @@ -45,24 +43,16 @@ describe("protocol", () => { expect(size.cols).toBe(80); }); - it("keeps legacy ATTACH byte-identical and appends set flags as a flag byte", () => { - const legacy = encodeAttach(24, 80); - expect(legacy).toEqual( + it("encodes ATTACH as a plain 4-byte size payload", () => { + const attach = encodeAttach(24, 80); + expect(attach).toEqual( encodePacket(MessageType.ATTACH, Buffer.from([0, 24, 0, 80])), ); - // An explicitly empty flag set stays on the legacy 4-byte frame. - expect(encodeAttach(24, 80, 0)).toEqual(legacy); const reader = new PacketReader(); - const [forced] = reader.feed( - encodeAttach(24, 80, ATTACH_FLAG_FORCE_RESIZE), - ); - expect(forced.payload).toEqual(Buffer.from([0, 24, 0, 80, 2])); - expect(decodeSize(forced.payload)).toEqual({ rows: 24, cols: 80 }); - expect(decodeAttachFlags(forced.payload) & ATTACH_FLAG_FORCE_RESIZE).toBe( - ATTACH_FLAG_FORCE_RESIZE, - ); - expect(decodeAttachFlags(Buffer.from([0, 24, 0, 80]))).toBe(0); + const [packet] = reader.feed(attach); + expect(packet.payload).toEqual(Buffer.from([0, 24, 0, 80])); + expect(decodeSize(packet.payload)).toEqual({ rows: 24, cols: 80 }); }); it("round-trips a DETACH packet", () => {