From 22409acd1257c0398595441b64b140c69b1bcfe9 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:19:23 +0200 Subject: [PATCH 1/2] feat(cli): add attach-only mode --- CHANGELOG.md | 7 + README.md | 1 + src/cli.ts | 32 +++- src/completions.ts | 1 + tests/attach-no-restart.test.ts | 287 ++++++++++++++++++++++++++++++++ vitest.config.ts | 1 + 6 files changed, 324 insertions(+), 5 deletions(-) create mode 100644 tests/attach-no-restart.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ee0a5..722bab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +### Attach-only CLI policy + +- `pty attach --no-restart ` attaches only to a currently running + session. Missing, exited, or vanished sessions fail without prompting or + executing retained launch metadata. Existing interactive prompting and + `--auto-restart` behavior are unchanged. + ### Generation-safe removal and immediate same-name reuse - `pty rm` now returns success only after the removed session's daemon has diff --git a/README.md b/README.md index 1383324..08597ec 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,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-restart myserver # attach only; fail if not running 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/src/cli.ts b/src/cli.ts index 4377b99..df6d311 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -104,12 +104,14 @@ Examples: pty run -- node server.js pty run -d --name "API" --tag role=web --env PORT=3000 -- node server.js`, - attach: `Usage: pty attach [-r] [--force] [--remote ] + attach: `Usage: pty attach [-r|--no-restart] [--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-restart Attach only while the session is running; never prompt + or execute its stored command --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 @@ -117,6 +119,7 @@ Flags: Examples: pty attach myserver pty attach -r myserver + pty attach --no-restart myserver pty attach --remote hetzner myshell`, exec: `Usage: pty exec -- [args...] @@ -412,6 +415,7 @@ Attach & interact: pty attach Attach to an existing session (alias: pty a) pty attach --force Attach even from inside another pty session (nested) pty attach -r Attach, auto-restart if the session is exited + pty attach --no-restart Attach only; fail if the session is not running pty attach --remote Attach a session on a fabric peer (over fabric) pty exec -- [args...] Replace the current session's process (inside a session) pty send "text" Send raw text (no implicit newline) @@ -885,12 +889,14 @@ async function main(): Promise { case "attach": case "a": { let autoRestart = false; + let noRestart = false; let force = 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-restart") noRestart = true; else if (a === "--force") force = true; else if (a === "--remote" && ai + 1 < args.length) { attachRemotePeer = args[++ai]; } else if (!attachName) attachName = a; @@ -900,7 +906,11 @@ async function main(): Promise { } } if (!attachName) { - console.error("Usage: pty attach [-r|--auto-restart] [--force] [--remote ] "); + console.error("Usage: pty attach [-r|--auto-restart|--no-restart] [--force] [--remote ] "); + process.exit(1); + } + if (autoRestart && noRestart) { + console.error("pty attach: --auto-restart and --no-restart are mutually exclusive"); process.exit(1); } // Nesting guard runs BEFORE name validation / ref resolution. A nested @@ -920,7 +930,9 @@ async function main(): Promise { await cmdAttachRemote(attachRemotePeer, attachName); } else { const resolvedAttachName = await resolveRef(attachName); - await cmdAttach(resolvedAttachName, autoRestart, force); + const restartPolicy: AttachRestartPolicy = + noRestart ? "never" : autoRestart ? "always" : "prompt"; + await cmdAttach(resolvedAttachName, restartPolicy, force); } break; } @@ -1578,9 +1590,11 @@ async function cmdRun( doAttach(name); } +type AttachRestartPolicy = "prompt" | "always" | "never"; + async function cmdAttach( name: string, - autoRestart = false, + restartPolicy: AttachRestartPolicy = "prompt", _force = false, ): Promise { // Nesting guard runs in the dispatcher (before name resolution) so the @@ -1600,8 +1614,16 @@ async function cmdAttach( return; } + // Attach-only callers are relays/supervisors that must never turn future + // input into permission to execute retained launch metadata. Refuse before + // entering the dead-session presentation/restart path. + if (restartPolicy === "never") { + console.error(`Session "${name}" is not running (status: ${session.status}).`); + process.exit(1); + } + // Dead session — show last lines and offer to restart - await handleDeadSession(session, autoRestart); + await handleDeadSession(session, restartPolicy === "always"); } async function handleDeadSession( diff --git a/src/completions.ts b/src/completions.ts index b89c3ab..528083e 100644 --- a/src/completions.ts +++ b/src/completions.ts @@ -85,6 +85,7 @@ const COMMANDS: readonly CommandSpec[] = [ dynamic: "sessions", flags: [ { name: "auto-restart", short: "r", desc: "Auto-restart if the session is exited" }, + { name: "no-restart", desc: "Attach only; never prompt or restart an exited session" }, { name: "force", desc: "Attach even from inside another pty" }, { name: "remote", desc: "Attach a session on a fabric peer" }, ], diff --git a/tests/attach-no-restart.test.ts b/tests/attach-no-restart.test.ts new file mode 100644 index 0000000..06437a4 --- /dev/null +++ b/tests/attach-no-restart.test.ts @@ -0,0 +1,287 @@ +import { afterAll, describe, expect, it } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as nodePty from "node-pty"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { terminateAndWait } from "./setup/processes.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const cliPath = path.join(__dirname, "..", "dist", "cli.js"); +const nodeBin = process.execPath; +const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-attach-no-restart-")); +const daemonPids = new Set(); + +afterAll(async () => { + await terminateAndWait(daemonPids); + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); +}); + +function env(root: string): Record { + const result: Record = { + ...(process.env as Record), + PTY_ROOT: root, + PTY_ROOT_LEGACY_SILENT: "1", + }; + delete result.PTY_SESSION; + delete result.PTY_SERVER_CONFIG; + return result; +} + +function runCli(root: string, args: string[]) { + return spawnSync(nodeBin, [cliPath, ...args], { + env: env(root), + encoding: "utf8", + timeout: 15_000, + }); +} + +function waitUntil(predicate: () => boolean, timeoutMs = 8_000): void { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); + } + throw new Error(`condition not met within ${timeoutMs}ms`); +} + +function readInvocationCount(marker: string): number { + try { + return fs.readFileSync(marker, "utf8").trim().split("\n").filter(Boolean).length; + } catch { + return 0; + } +} + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function readEventCount(root: string, name: string, event: string): number { + const eventsPath = path.join(root, `${name}.events.jsonl`); + try { + return fs.readFileSync(eventsPath, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)) + .filter((entry) => entry.type === event).length; + } catch { + return 0; + } +} + +function spawnRetainedOnce(root: string, name: string, marker: string): void { + const command = `printf 'started\\n' >> ${JSON.stringify(marker)}; exit 42`; + const result = runCli(root, [ + "run", "-d", "--id", name, "--tag", "keep=true", + "--", "sh", "-c", command, + ]); + expect(result.status, result.stderr).toBe(0); + const daemonPid = Number(fs.readFileSync(path.join(root, `${name}.pid`), "utf8").trim()); + daemonPids.add(daemonPid); + waitUntil(() => { + try { + const metadata = JSON.parse(fs.readFileSync(path.join(root, `${name}.json`), "utf8")); + return metadata.exitCode === 42 && typeof metadata.exitedAt === "string"; + } catch { + return false; + } + }); + // Exit metadata is published before the daemon's deliberate 500ms grace + // period ends. This fixture models a fully dead retained session. + waitUntil(() => !processIsAlive(daemonPid)); + expect(readInvocationCount(marker)).toBe(1); +} + +function runInTerminal( + root: string, + args: string[], + delayedInput?: { text: string; delayMs: number }, +): Promise<{ code: number; signal?: number; output: string }> { + return new Promise((resolve, reject) => { + let output = ""; + const proc = nodePty.spawn(nodeBin, [cliPath, ...args], { + name: "xterm-256color", + cols: 100, + rows: 30, + env: env(root), + }); + const timeout = setTimeout(() => { + try { proc.kill("SIGKILL"); } catch {} + reject(new Error(`terminal command timed out\n${output}`)); + }, 10_000); + proc.onData((data) => { output += data; }); + proc.onExit(({ exitCode, signal }) => { + clearTimeout(timeout); + resolve({ code: exitCode, signal, output }); + }); + if (delayedInput) { + setTimeout(() => { + try { proc.write(delayedInput.text); } catch {} + }, delayedInput.delayMs); + } + }); +} + +describe("pty attach --no-restart", () => { + it("advertises the attach-only policy in focused help", () => { + const root = fs.mkdtempSync(path.join(testRoot, "help-")); + const result = runCli(root, ["attach", "--help"]); + expect(result.status).toBe(0); + expect(result.stdout).toContain("--no-restart"); + expect(result.stdout).toMatch(/never prompt/); + }); + + it("rejects contradictory restart policies", () => { + const root = fs.mkdtempSync(path.join(testRoot, "conflict-")); + const result = runCli(root, ["attach", "--no-restart", "--auto-restart", "missing"]); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/mutually exclusive/); + }); + + it("returns nonzero without prompting for a missing session", () => { + const root = fs.mkdtempSync(path.join(testRoot, "missing-")); + const result = runCli(root, ["attach", "--no-restart", "missing"]); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('Session "missing" not found.'); + expect(result.stdout).not.toContain("Restart?"); + }); + + it("refuses an exited session before delayed relay input can restart it", async () => { + const root = fs.mkdtempSync(path.join(testRoot, "exited-")); + const marker = path.join(root, "invocations"); + const name = "exited-target"; + spawnRetainedOnce(root, name, marker); + + const result = await runInTerminal( + root, + ["attach", "--no-restart", name], + { text: "future-relay-input\r", delayMs: 250 }, + ); + + expect(result.code).not.toBe(0); + expect(result.output).not.toContain("Restart?"); + expect(result.output).not.toContain("Command was:"); + expect(readInvocationCount(marker)).toBe(1); + expect(readEventCount(root, name, "session_start")).toBe(1); + const pidPath = path.join(root, `${name}.pid`); + if (fs.existsSync(pidPath)) { + const retainedPid = Number(fs.readFileSync(pidPath, "utf8").trim()); + expect(processIsAlive(retainedPid)).toBe(false); + } + }); + + it("refuses a vanished session without evaluating its stored command", async () => { + const root = fs.mkdtempSync(path.join(testRoot, "vanished-")); + const marker = path.join(root, "must-not-exist"); + const name = "vanished-target"; + fs.writeFileSync(path.join(root, `${name}.json`), JSON.stringify({ + command: "sh", + args: ["-c", `printf 'restarted\\n' >> ${JSON.stringify(marker)}`], + displayCommand: "synthetic stored command", + cwd: root, + createdAt: new Date().toISOString(), + tags: { keep: "true" }, + })); + + const result = await runInTerminal( + root, + ["attach", "--no-restart", name], + { text: "future-relay-input\r", delayMs: 250 }, + ); + + expect(result.code).not.toBe(0); + expect(result.output).toContain("is not running"); + expect(result.output).toContain("vanished"); + expect(result.output).not.toContain("Restart?"); + expect(result.output).not.toContain("synthetic stored command"); + expect(fs.existsSync(marker)).toBe(false); + }); + + it("attaches to a running daemon, then exits with it without a second incarnation", async () => { + const root = fs.mkdtempSync(path.join(testRoot, "running-")); + const marker = path.join(root, "invocations"); + const name = "running-target"; + const command = + `printf 'started\\n' >> ${JSON.stringify(marker)}; printf 'ATTACH_READY\\n'; read line; exit 37`; + const created = runCli(root, [ + "run", "-d", "--id", name, "--tag", "keep=true", + "--", "sh", "-c", command, + ]); + expect(created.status, created.stderr).toBe(0); + const daemonPid = Number(fs.readFileSync(path.join(root, `${name}.pid`), "utf8").trim()); + daemonPids.add(daemonPid); + waitUntil(() => readInvocationCount(marker) === 1); + + const attached = nodePty.spawn(nodeBin, [cliPath, "attach", "--no-restart", name], { + name: "xterm-256color", + cols: 100, + rows: 30, + env: env(root), + }); + let output = ""; + attached.onData((data) => { output += data; }); + const exited = new Promise<{ exitCode: number; signal?: number }>((resolve) => { + attached.onExit(resolve); + }); + await new Promise((resolve, reject) => { + const started = Date.now(); + const poll = () => { + if (output.includes("ATTACH_READY")) resolve(); + else if (Date.now() - started > 8_000) reject(new Error(`attach did not become ready\n${output}`)); + else setTimeout(poll, 25); + }; + poll(); + }); + attached.write("finish\r"); + const result = await Promise.race([ + exited, + new Promise((_, reject) => setTimeout(() => reject(new Error(`attach did not exit\n${output}`)), 8_000)), + ]); + expect(result.exitCode).toBe(37); + expect(output).toContain(`${name} exited with code 37`); + + try { attached.write("future-relay-input\r"); } catch {} + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(readInvocationCount(marker)).toBe(1); + expect(readEventCount(root, name, "session_start")).toBe(1); + }, 20_000); + + it("preserves the default prompt-and-restart behavior", async () => { + const root = fs.mkdtempSync(path.join(testRoot, "legacy-")); + const marker = path.join(root, "invocations"); + const name = "legacy-target"; + spawnRetainedOnce(root, name, marker); + + const result = await runInTerminal( + root, + ["attach", name], + { text: "future-relay-input\r", delayMs: 250 }, + ); + + expect(result.output).toContain("Restart? [Y/n]"); + expect(readInvocationCount(marker)).toBe(2); + // A restart begins a new event log rather than appending to the old one. + expect(readEventCount(root, name, "session_start")).toBe(1); + }); + + it("preserves --auto-restart behavior without prompting", async () => { + const root = fs.mkdtempSync(path.join(testRoot, "automatic-")); + const marker = path.join(root, "invocations"); + const name = "automatic-target"; + spawnRetainedOnce(root, name, marker); + + const result = await runInTerminal(root, ["attach", "--auto-restart", name]); + + expect(result.output).not.toContain("Restart?"); + expect(readInvocationCount(marker)).toBe(2); + expect(readEventCount(root, name, "session_start")).toBe(1); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 12005cc..ce8325d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -16,6 +16,7 @@ const HEAVY_PTY_TESTS = [ "tests/remote-fabric.test.ts", "tests/remote-reconnect.test.ts", "tests/remote-exec-bridge.test.ts", + "tests/attach-no-restart.test.ts", // Real-IO / real-daemon timing tests that TIME OUT (5s default) under sustained // parallel load — they spawn daemons / do heavy event-log IO / drive a TUI and // pass in isolation but flake when the box is CPU-starved (many parallel PTY From 36547b02c32116cca81df4147a638da7df6981a5 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:34:44 +0200 Subject: [PATCH 2/2] fix(completions): refresh shipped attach flags --- completions/pty.bash | 2 +- completions/pty.fish | 1 + completions/pty.zsh | 1 + tests/completions.test.ts | 10 ++++++++++ 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/completions/pty.bash b/completions/pty.bash index 3f45a28..d9c17d8 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 --remote" -- "${cur}")) + COMPREPLY=($(compgen -W "-r --auto-restart --no-restart --force --remote" -- "${cur}")) else COMPREPLY=($(compgen -W "${names}" -- "${cur}")) fi diff --git a/completions/pty.fish b/completions/pty.fish index 87f397e..78c6c27 100644 --- a/completions/pty.fish +++ b/completions/pty.fish @@ -76,6 +76,7 @@ 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-restart -d 'Attach only; never prompt or restart an exited session' 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 6e49a0d..84cb4ac 100644 --- a/completions/pty.zsh +++ b/completions/pty.zsh @@ -69,6 +69,7 @@ _pty() { attach|a) _arguments \ '(r --auto-restart){r,--auto-restart}[Auto-restart if the session is exited]' \ + '--no-restart[Attach only; never prompt or restart an exited session]' \ '--force[Attach even from inside another pty]' \ '--remote[Attach a session on a fabric peer]' \ '1:session:_pty_sessions' diff --git a/tests/completions.test.ts b/tests/completions.test.ts index dc40aeb..8650412 100644 --- a/tests/completions.test.ts +++ b/tests/completions.test.ts @@ -63,6 +63,16 @@ describe("completion spec parity with COMMAND_HELP", () => { }); describe("pty completions ", () => { + it("matches every checked-in completion artifact", () => { + for (const shell of ["fish", "bash", "zsh"]) { + const checkedIn = fs.readFileSync( + path.join(__dirname, "..", "completions", `pty.${shell}`), + "utf8", + ); + expect(gen(shell), `completions/pty.${shell} is stale`).toBe(checkedIn); + } + }); + it("offers pty run --env in every generated shell", () => { const markers = { fish: "-l env", bash: "--env", zsh: "--env" } as const; for (const shell of ["fish", "bash", "zsh"] as const) {