This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Nesting prevention: refuse client-inside-a-client for attach, interactive, restart-then-attach, and run -a when target is running

Request from pty-layout-claude: every pty-layout pane runs a shell
with PTY_SESSION set, so commands that silently started a nested pty
client routed detach keybindings to the outer client and left users
tangled with no clean way to exit.

New helper ensureNotNested(cmd, { force?, hint? }) in cli.ts. Called
from:

- pty attach / pty a / pty attach -r: refuse early (before
resolveRef) so even a mistyped ref yields the nesting hint instead
of a "not found" error. Hint mentions Ctrl+\\ to detach and pty-
layouts ^]n picker.
- pty restart <ref>: restart still runs (its independent of attach),
but the trailing doAttach is skipped and a one-line "(not attached:
already inside pty session X)" notice prints. -y / --yes still
skips the kill-confirm prompt; --force restores restart+attach.
- pty / pty i / pty interactive: refuse to open the TUI picker;
picker rendering inside a session with broken detach was the worst
footgun of the bunch.
- pty run -a: narrows the existing "already inside pty session,
running directly" behavior — if the target is already running,
refuse rather than silently dropping -a. Target-not-running case
keeps the run-directly behavior; -a is a no-op in that situation
anyway.

--force across all four to opt back into the old behavior (nested
clients are occasionally intentional: debugging, screen-sharing
demos).

Latent test-harness bug surfaced by the guard: Session.spawn
(@myobie/pty/testing) was leaking the harness's own PTY_SESSION into
the child env, so every interactive-TUI test in tui.test.ts tripped
the new guard. Scrub PTY_SESSION alongside the existing
PTY_SERVER_CONFIG scrubbing so Session.spawn simulates a clean user
shell regardless of what the harness is running inside.

Nathan Herald (Apr 24, 2026, 10:48 AM +0200) 5c6dc967 eef3f1c1

+417 -17
+8
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### Nesting prevention (client-inside-a-client refusal) 6 + - **`pty attach`, `pty a`, and `pty attach -r`** now refuse to run when `$PTY_SESSION` is set. Attaching inside an existing client nested two clients and sent detach keybindings to the outer one, leaving users tangled. The guard fires before ref resolution, so even a mistyped name yields the nesting hint instead of "Session not found." Error text points at `Ctrl+\\` to detach and at pty-layout's `^]n` for users inside a tiled layout. `--force` restores the old behavior. 7 + - **`pty restart <ref>`** still restarts the session when nested (that operation is independent of attach), but skips the trailing `doAttach` and prints `(not attached: already inside pty session "X")`. `--force` restores the old restart-then-attach behavior. `-y` / `--yes` (which skips the kill-confirm prompt) is unchanged and independent. 8 + - **`pty` / `pty i` / `pty interactive`** refuse to open the TUI picker when nested — the picker rendering inside a session with broken detach was a worse footgun than any other vector. `--force` opens it anyway (useful for debugging the TUI itself). 9 + - **`pty run -a`** narrows the existing "already inside pty session, running directly" behavior: if `-a` was given and the target session is already running, refuse rather than silently dropping `-a` and running the command in-place. The target-not-running case keeps the run-directly behavior (there's no session to attach to). `--force` skips the new check. 10 + - **Shared helper `ensureNotNested(cmd, { force?, hint? })`** in `src/cli.ts` — early exit with a consistent message and per-command hint text. 11 + - **`Session.spawn` (the `@myobie/pty/testing` harness) now scrubs `PTY_SESSION` from the child env** in addition to the existing `PTY_SERVER_CONFIG` scrubbing, so tests running inside a pty session don't leak that context into the CLI they're exercising. Without this, every interactive-TUI test would trip the new guard. 12 + 5 13 ### Events & state 6 14 - **`pty emit` — publish user events.** `pty emit <type> [--json <payload>] [--text <string>]` appends an event of type `user.<name>` to a session's `.events.jsonl` file. `<type>` must start with `user.` (the `user.*` namespace is reserved for arbitrary app events and will never collide with built-in types). Inside a pty session the ref is omitted — `$PTY_SESSION` resolves it — so scripts inside a session can just run `pty emit user.deploy.started --json '{"commit":"abc123"}'`. Consumers tail via the existing `EventFollower` API. Programmatic access: `emitUserEvent(sessionName, type, { data?, text? })` on `@myobie/pty/client`. 7 15 - **`pty state` — per-session JSON key/value bag.** Each session's metadata file now carries an optional `state: Record<string, unknown>` field with four CLI subcommands:
+118 -17
src/cli.ts
··· 39 39 40 40 // Lazy-load the interactive TUI so non-interactive commands don't crash when 41 41 // the caller's cwd was deleted (the TUI module evaluates process.cwd() at load). 42 - async function runInteractive(options?: { preselectNew?: boolean; filterTags?: Record<string, string> }): Promise<void> { 42 + async function runInteractive(options?: { preselectNew?: boolean; filterTags?: Record<string, string>; force?: boolean }): Promise<void> { 43 + ensureNotNested("interactive", { 44 + force: options?.force, 45 + hint: 46 + " The interactive picker would render inside your current session and detach would route to the outer client.\n" + 47 + " Detach first (Ctrl+\\) and run `pty` from outside, or pass --force to open the picker anyway.", 48 + }); 43 49 const mod = await import("./tui/interactive.ts"); 44 50 await mod.runInteractive(options); 45 51 } ··· 72 78 pty rename --show <ref> Show current displayName for <ref> 73 79 pty rename --clear [ref] Remove displayName (ref required outside a session) 74 80 pty attach <name> Attach to an existing session 81 + pty attach --force <name> Attach even when already inside a pty session (nested) 75 82 pty exec -- <command> [args...] Replace the current session's command 76 83 pty attach -r <name> Attach, auto-restart if exited 77 84 pty peek <name> Print current screen and exit ··· 140 147 return session.name; 141 148 } 142 149 150 + /** Refuse a command that would start a nested client inside an existing 151 + * pty session. Several commands (attach, restart-then-attach, the 152 + * interactive picker, run -a when the target is running) silently created 153 + * a client-inside-a-client, routing detach keybindings to the outer 154 + * client and tangling the user up. `--force` opts back into the old 155 + * behavior for the rare cases where nesting is intentional (debugging, 156 + * screen-sharing demos). Prints + exits; does not return on refusal. */ 157 + function ensureNotNested( 158 + cmd: string, 159 + opts: { force?: boolean; hint?: string } = {}, 160 + ): void { 161 + if (opts.force) return; 162 + const nested = process.env.PTY_SESSION; 163 + if (!nested) return; 164 + console.error(`pty ${cmd}: already inside pty session "${nested}".`); 165 + if (opts.hint) console.error(opts.hint); 166 + else console.error(" Pass --force to override."); 167 + process.exit(1); 168 + } 169 + 143 170 /** Generate a short random session id. Base32 (Crockford-ish, no 0/O/1/I 144 171 * confusion). 8 chars = 40 bits — plenty of headroom against collisions 145 172 * even with thousands of sessions per machine. */ ··· 192 219 193 220 let preselectNew = false; 194 221 let interactiveFilterTags: Record<string, string> = {}; 222 + let interactiveForce = false; 195 223 if (!subcommand || subcommand === "i" || subcommand === "interactive") { 196 224 preselectNew = args.includes("--preselect-new"); 225 + interactiveForce = args.includes("--force"); 197 226 interactiveFilterTags = extractFilterTags(args); 198 227 } 199 - const dispatchArgs = args.filter((a) => a !== "--preselect-new"); 228 + const dispatchArgs = args.filter((a) => a !== "--preselect-new" && a !== "--force"); 200 229 201 230 if (dispatchArgs.length === 0) { 202 - await runInteractive({ preselectNew, filterTags: interactiveFilterTags }); 231 + await runInteractive({ preselectNew, filterTags: interactiveFilterTags, force: interactiveForce }); 203 232 return; 204 233 } 205 234 ··· 208 237 switch (command) { 209 238 case "interactive": 210 239 case "i": { 211 - await runInteractive({ preselectNew, filterTags: interactiveFilterTags }); 240 + await runInteractive({ preselectNew, filterTags: interactiveFilterTags, force: interactiveForce }); 212 241 break; 213 242 } 214 243 ··· 219 248 let ephemeral = false; 220 249 let isolateEnv = false; 221 250 let noDisplayName = false; 251 + let force = false; 222 252 let name: string | null = null; 223 253 let cwd: string | null = null; 224 254 const tags: Record<string, string> = {}; ··· 229 259 else if (args[i] === "-e" || args[i] === "--ephemeral") { ephemeral = true; i++; } 230 260 else if (args[i] === "--isolate-env") { isolateEnv = true; i++; } 231 261 else if (args[i] === "--no-display-name") { noDisplayName = true; i++; } 262 + else if (args[i] === "--force") { force = true; i++; } 232 263 else if (args[i] === "--name" && i + 1 < args.length) { name = args[i + 1]; i += 2; } 233 264 else if (args[i] === "--cwd" && i + 1 < args.length) { cwd = args[i + 1]; i += 2; } 234 265 else if (args[i] === "--tag" && i + 1 < args.length) { ··· 288 319 process.exit(1); 289 320 } 290 321 291 - // Nesting prevention: if inside a pty session and not detaching, exec directly 322 + // Nesting prevention: if inside a pty session and not detaching, exec 323 + // directly. The -a branch is narrower: if the caller asked to attach- 324 + // if-running and the target IS running, attaching would nest a client — 325 + // error with a clear message unless --force. If the target isn't 326 + // running, the original "exec directly" behavior still makes sense 327 + // (there's no session to attach to anyway). 292 328 if (process.env.PTY_SESSION && !detach) { 329 + if (attachExisting && name && !force) { 330 + const existing = await getSession(name); 331 + if (existing && existing.status === "running") { 332 + ensureNotNested("run -a", { 333 + force: false, 334 + hint: 335 + ` Target session "${name}" is already running; attaching would nest a client inside the current session.\n` + 336 + " Pass --force to attach anyway, or detach first (Ctrl+\\) and re-run from outside.", 337 + }); 338 + } 339 + } 293 340 console.error( 294 341 `Already inside pty session "${process.env.PTY_SESSION}", running directly.` 295 342 ); ··· 345 392 346 393 case "attach": 347 394 case "a": { 348 - const autoRestart = 349 - args[1] === "--auto-restart" || args[1] === "-r"; 350 - const attachName = autoRestart ? args[2] : args[1]; 395 + let autoRestart = false; 396 + let force = false; 397 + let attachName: string | null = null; 398 + for (let ai = 1; ai < args.length; ai++) { 399 + const a = args[ai]; 400 + if (a === "--auto-restart" || a === "-r") autoRestart = true; 401 + else if (a === "--force") force = true; 402 + else if (!attachName) attachName = a; 403 + else { 404 + console.error(`pty attach: unexpected argument "${a}"`); 405 + process.exit(1); 406 + } 407 + } 351 408 if (!attachName) { 352 - console.error("Usage: pty attach [-r|--auto-restart] <name>"); 409 + console.error("Usage: pty attach [-r|--auto-restart] [--force] <name>"); 353 410 process.exit(1); 354 411 } 412 + // Nesting guard runs BEFORE name validation / ref resolution. A nested 413 + // caller gets the informative nesting message even if they mistyped 414 + // the session name — otherwise they'd fix the typo, try again, and 415 + // only then discover they shouldn't attach at all. 416 + ensureNotNested("attach", { 417 + force, 418 + hint: 419 + " Attaching now would nest a client inside the current session — detach keys route to the outer client and get tangled.\n" + 420 + " Detach first (Ctrl+\\) or, from inside pty-layout, use ^]n to pick a session.\n" + 421 + " Pass --force to attach anyway (nested clients are usually a mistake).", 422 + }); 355 423 try { 356 424 validateName(attachName); 357 425 } catch (e: any) { ··· 359 427 process.exit(1); 360 428 } 361 429 const resolvedAttachName = await resolveRef(attachName); 362 - await cmdAttach(resolvedAttachName, autoRestart); 430 + await cmdAttach(resolvedAttachName, autoRestart, force); 363 431 break; 364 432 } 365 433 ··· 607 675 } 608 676 609 677 case "restart": { 610 - const forceRestart = args[1] === "-y" || args[1] === "--yes"; 611 - const restartName = forceRestart ? args[2] : args[1]; 678 + // -y / --yes: skip the "kill and restart?" prompt when already running 679 + // --force: also skip the nesting guard (restart + attach even when 680 + // already inside a pty session — nested client, caveat emptor) 681 + let yes = false; 682 + let force = false; 683 + let restartName: string | null = null; 684 + for (let ai = 1; ai < args.length; ai++) { 685 + const a = args[ai]; 686 + if (a === "-y" || a === "--yes") yes = true; 687 + else if (a === "--force") force = true; 688 + else if (!restartName) restartName = a; 689 + else { 690 + console.error(`pty restart: unexpected argument "${a}"`); 691 + process.exit(1); 692 + } 693 + } 612 694 if (!restartName) { 613 - console.error("Usage: pty restart [-y] <name>"); 695 + console.error("Usage: pty restart [-y] [--force] <name>"); 614 696 process.exit(1); 615 697 } 616 698 const resolvedRestartName = await resolveRef(restartName); 617 - await cmdRestart(resolvedRestartName, forceRestart); 699 + await cmdRestart(resolvedRestartName, yes, force); 618 700 break; 619 701 } 620 702 ··· 1037 1119 1038 1120 async function cmdAttach( 1039 1121 name: string, 1040 - autoRestart = false 1122 + autoRestart = false, 1123 + _force = false, 1041 1124 ): Promise<void> { 1125 + // Nesting guard runs in the dispatcher (before name resolution) so the 1126 + // user gets the nesting hint even for typo'd refs. cmdAttach itself is 1127 + // only reached once that check has passed; _force is retained in the 1128 + // signature for clarity and potential future use. 1129 + void _force; 1042 1130 const session = await getSession(name); 1043 1131 1044 1132 if (!session) { ··· 2903 2991 } 2904 2992 } 2905 2993 2906 - async function cmdRestart(name: string, force = false): Promise<void> { 2994 + async function cmdRestart( 2995 + name: string, 2996 + yes = false, 2997 + forceNested = false, 2998 + ): Promise<void> { 2907 2999 try { 2908 3000 validateName(name); 2909 3001 } catch (e: any) { ··· 2926 3018 } 2927 3019 2928 3020 if (session.status === "running" && session.pid) { 2929 - if (!force) { 3021 + if (!yes) { 2930 3022 const answer = await ask(`Session "${name}" is running. Kill and restart? [Y/n] `); 2931 3023 if (answer.toLowerCase() === "n") { 2932 3024 process.exit(0); ··· 2943 3035 cleanupAll(name); 2944 3036 await spawnDaemon({ name, command: meta.command, args: meta.args, displayCommand: meta.displayCommand, cwd: meta.cwd, tags: meta.tags }); 2945 3037 console.log(`Session "${name}" restarted.`); 3038 + 3039 + // Nesting guard: restart itself is fine, but attaching would nest a client 3040 + // inside the current session. Print a note and bail unless --force. 3041 + const nested = process.env.PTY_SESSION; 3042 + if (nested && !forceNested) { 3043 + console.log(` (not attached: already inside pty session "${nested}". Pass --force to attach anyway.)`); 3044 + return; 3045 + } 3046 + 2946 3047 doAttach(name); 2947 3048 } 2948 3049
+5
src/testing/session.ts
··· 81 81 ...(process.env as Record<string, string>), 82 82 ...opts.env, 83 83 }; 84 + // Scrub pty-internal env vars so the spawned CLI sees a clean user 85 + // environment, not the test harness's own pty context. Without this, 86 + // any test that runs `pty` inside a Session inherits the harness's 87 + // PTY_SESSION and trips the nesting-prevention guard. 84 88 delete env.PTY_SERVER_CONFIG; 89 + delete env.PTY_SESSION; 85 90 86 91 const proc = pty.spawn(command, args, { 87 92 name: "xterm-256color",
+286
tests/nesting-prevention.test.ts
··· 1 + // Nesting prevention: pty attach / restart / interactive / run -a all 2 + // refuse to start a client inside a session that's already a pty 3 + // client, because detach keybindings would route to the outer client 4 + // and the user gets tangled. --force overrides. 5 + // 6 + // Request originated from pty-layout-claude: every pty-layout pane 7 + // runs a shell with PTY_SESSION set, so `pty attach <other>` inside 8 + // a pane would silently create a nested client. 9 + 10 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 11 + import * as fs from "node:fs"; 12 + import * as os from "node:os"; 13 + import * as path from "node:path"; 14 + import { fileURLToPath } from "node:url"; 15 + import { spawn, spawnSync } from "node:child_process"; 16 + 17 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 18 + const nodeBin = process.execPath; 19 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 20 + const serverModule = path.join(__dirname, "..", "dist", "server.js"); 21 + 22 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-nest-")); 23 + afterAll(() => { 24 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 25 + }); 26 + 27 + let bgPids: number[] = []; 28 + let sessionDirs: string[] = []; 29 + 30 + function makeSessionDir(): string { 31 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 32 + sessionDirs.push(dir); 33 + return dir; 34 + } 35 + 36 + let nameCounter = 0; 37 + function uniqueName(): string { 38 + return `nst${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; 39 + } 40 + 41 + async function startDaemon(sessionDir: string, name: string, command = "cat"): Promise<number> { 42 + const config = JSON.stringify({ 43 + name, command, args: [], displayCommand: command, 44 + cwd: os.tmpdir(), rows: 24, cols: 80, 45 + }); 46 + const child = spawn(nodeBin, [serverModule], { 47 + detached: true, 48 + stdio: ["ignore", "ignore", "pipe"], 49 + env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, 50 + }); 51 + let stderr = ""; 52 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 53 + let exitCode: number | null = null; 54 + child.on("exit", (code) => { exitCode = code; }); 55 + (child.stderr as any)?.unref?.(); 56 + child.unref(); 57 + const socketPath = path.join(sessionDir, `${name}.sock`); 58 + const start = Date.now(); 59 + while (Date.now() - start < 5000) { 60 + if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); 61 + try { 62 + fs.statSync(socketPath); 63 + await new Promise((r) => setTimeout(r, 100)); 64 + bgPids.push(child.pid!); 65 + return child.pid!; 66 + } catch {} 67 + await new Promise((r) => setTimeout(r, 50)); 68 + } 69 + throw new Error("Timeout waiting for daemon"); 70 + } 71 + 72 + // Run the CLI with a controlled PTY_SESSION env — this is the key 73 + // fixture for nesting-prevention tests. The outer vitest process may 74 + // or may not have PTY_SESSION set (it usually is when the harness 75 + // itself runs inside a pty session), so we always set it explicitly. 76 + function runCliNested( 77 + sessionDir: string, 78 + ptySession: string | null, 79 + ...args: string[] 80 + ) { 81 + const env: Record<string, string | undefined> = { 82 + ...process.env, 83 + PTY_SESSION_DIR: sessionDir, 84 + }; 85 + if (ptySession === null) { 86 + delete env.PTY_SESSION; 87 + } else { 88 + env.PTY_SESSION = ptySession; 89 + } 90 + return spawnSync(nodeBin, [cliPath, ...args], { 91 + env: env as Record<string, string>, 92 + encoding: "utf-8", 93 + timeout: 10_000, 94 + }); 95 + } 96 + 97 + afterEach(() => { 98 + for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 99 + bgPids = []; 100 + for (const dir of sessionDirs) { 101 + try { 102 + for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 103 + } catch {} 104 + } 105 + sessionDirs = []; 106 + }); 107 + 108 + describe("pty attach", () => { 109 + it("refuses when PTY_SESSION is set; exits non-zero with a clear message", async () => { 110 + const dir = makeSessionDir(); 111 + const target = uniqueName(); 112 + await startDaemon(dir, target); 113 + 114 + const r = runCliNested(dir, "outer-session", "attach", target); 115 + expect(r.status).not.toBe(0); 116 + expect(r.stderr).toContain('already inside pty session "outer-session"'); 117 + expect(r.stderr).toMatch(/--force/i); 118 + }, 15000); 119 + 120 + it("refuses even for a dead-session attach (the restart-then-attach path)", async () => { 121 + const dir = makeSessionDir(); 122 + const target = uniqueName(); 123 + // Write metadata only — no daemon — so cmdAttach takes the dead-session 124 + // branch that would otherwise call doAttach after restart. 125 + fs.writeFileSync( 126 + path.join(dir, `${target}.json`), 127 + JSON.stringify({ 128 + command: "cat", args: [], displayCommand: "cat", 129 + cwd: os.tmpdir(), 130 + createdAt: new Date().toISOString(), 131 + exitedAt: new Date().toISOString(), 132 + exitCode: 0, 133 + }), 134 + ); 135 + 136 + const r = runCliNested(dir, "outer-session", "attach", "-r", target); 137 + expect(r.status).not.toBe(0); 138 + // Early guard — should not even reach the "Session X exited" / restart prompt. 139 + expect(r.stderr).toContain('already inside pty session "outer-session"'); 140 + expect(r.stdout).not.toContain("Restart?"); 141 + }, 15000); 142 + 143 + it("--force bypasses the guard — reaches the 'not found' check instead of the nesting refusal", () => { 144 + // Attach to a session that doesn't exist. Without --force: hits the 145 + // nesting guard and complains about nesting. With --force: skips the 146 + // guard and hits the normal "Session X not found." path. 147 + const dir = makeSessionDir(); 148 + const bogus = `no-such-${Math.random().toString(36).slice(2, 8)}`; 149 + 150 + const refused = runCliNested(dir, "outer-session", "attach", bogus); 151 + expect(refused.status).not.toBe(0); 152 + expect(refused.stderr).toContain('already inside pty session "outer-session"'); 153 + 154 + const forced = runCliNested(dir, "outer-session", "attach", "--force", bogus); 155 + expect(forced.status).not.toBe(0); 156 + expect(forced.stderr).not.toContain("already inside pty session"); 157 + expect(forced.stderr).toMatch(/not found/); 158 + }); 159 + 160 + it("--force can appear before or after -r in the argv", () => { 161 + // Same trick as above — use a non-existent session so the --force path 162 + // reaches the fast "not found" exit instead of the blocking attach. 163 + const dir = makeSessionDir(); 164 + const bogus = `no-such-${Math.random().toString(36).slice(2, 8)}`; 165 + 166 + const a = runCliNested(dir, "outer", "attach", "--force", "-r", bogus); 167 + expect(a.stderr).not.toContain("already inside pty session"); 168 + expect(a.stderr).toMatch(/not found/); 169 + 170 + const b = runCliNested(dir, "outer", "attach", "-r", "--force", bogus); 171 + expect(b.stderr).not.toContain("already inside pty session"); 172 + expect(b.stderr).toMatch(/not found/); 173 + }); 174 + }); 175 + 176 + describe("pty restart", () => { 177 + it("restarts the session but skips the trailing attach when nested", async () => { 178 + const dir = makeSessionDir(); 179 + const target = uniqueName(); 180 + await startDaemon(dir, target); 181 + 182 + const r = runCliNested(dir, "outer-session", "restart", "-y", target); 183 + expect(r.status).toBe(0); 184 + expect(r.stdout).toContain(`Session "${target}" restarted.`); 185 + expect(r.stdout).toMatch(/not attached.*outer-session/); 186 + }, 20000); 187 + 188 + it("--force restores restart+attach behavior (attach actually runs)", async () => { 189 + const dir = makeSessionDir(); 190 + const target = uniqueName(); 191 + await startDaemon(dir, target); 192 + 193 + // With --force we expect the old behavior: restart, then attach. Attach 194 + // blocks on stdin in a non-TTY; we just verify the "not attached" notice 195 + // is absent. 196 + const r = runCliNested(dir, "outer-session", "restart", "-y", "--force", target); 197 + expect(r.stdout).not.toMatch(/not attached/); 198 + }, 20000); 199 + 200 + it("non-nested restart unchanged (attaches after restart)", async () => { 201 + const dir = makeSessionDir(); 202 + const target = uniqueName(); 203 + await startDaemon(dir, target); 204 + 205 + const r = runCliNested(dir, null, "restart", "-y", target); 206 + expect(r.stdout).toContain(`Session "${target}" restarted.`); 207 + expect(r.stdout).not.toMatch(/not attached/); 208 + }, 20000); 209 + }); 210 + 211 + describe("pty interactive / bare pty", () => { 212 + it("refuses bare `pty` when nested", () => { 213 + const dir = makeSessionDir(); 214 + const r = runCliNested(dir, "outer-session"); 215 + expect(r.status).not.toBe(0); 216 + expect(r.stderr).toContain("already inside pty session"); 217 + expect(r.stderr).toMatch(/interactive picker|Ctrl/i); 218 + }); 219 + 220 + it("refuses `pty i` when nested", () => { 221 + const dir = makeSessionDir(); 222 + const r = runCliNested(dir, "outer-session", "i"); 223 + expect(r.status).not.toBe(0); 224 + expect(r.stderr).toContain("already inside pty session"); 225 + }); 226 + 227 + it("refuses `pty interactive` when nested", () => { 228 + const dir = makeSessionDir(); 229 + const r = runCliNested(dir, "outer-session", "interactive"); 230 + expect(r.status).not.toBe(0); 231 + expect(r.stderr).toContain("already inside pty session"); 232 + }); 233 + 234 + it("--force bypasses the guard (won't actually run the picker in a non-TTY env, but guard doesn't fire)", () => { 235 + // The TUI picker itself will bail quickly when stdin isn't a TTY, so we 236 + // just verify our nesting error isn't the reason for exiting. 237 + const dir = makeSessionDir(); 238 + const r = runCliNested(dir, "outer-session", "--force"); 239 + expect(r.stderr).not.toContain("already inside pty session"); 240 + }); 241 + }); 242 + 243 + describe("pty run -a", () => { 244 + it("refuses when target is already running AND nested", async () => { 245 + const dir = makeSessionDir(); 246 + const target = uniqueName(); 247 + await startDaemon(dir, target); 248 + 249 + const r = runCliNested(dir, "outer-session", "run", "-a", "--name", target, "--", "cat"); 250 + expect(r.status).not.toBe(0); 251 + expect(r.stderr).toContain('already inside pty session "outer-session"'); 252 + expect(r.stderr).toContain(target); 253 + }, 15000); 254 + 255 + it("falls through to run-directly when target is NOT running (current behavior preserved)", async () => { 256 + const dir = makeSessionDir(); 257 + const target = uniqueName(); 258 + // No daemon started — target doesn't exist. 259 + 260 + const r = runCliNested(dir, "outer-session", "run", "-a", "--name", target, "--", "true"); 261 + // Should hit the exec-directly path: exec `true` which exits 0. 262 + expect(r.stderr).toContain("Already inside pty session"); 263 + expect(r.stderr).toContain("running directly"); 264 + }, 10000); 265 + 266 + it("--force overrides when nested + target running", async () => { 267 + const dir = makeSessionDir(); 268 + const target = uniqueName(); 269 + await startDaemon(dir, target); 270 + 271 + const r = runCliNested(dir, "outer-session", "run", "-a", "--force", "--name", target, "--", "cat"); 272 + // With --force we fall through past the nesting guard. The child run 273 + // command will likely fail for a different reason (no daemon spawn in 274 + // the nested-exec path) — just confirm the nesting error didn't fire. 275 + expect(r.stderr).not.toMatch(/attaching would nest/); 276 + }, 15000); 277 + 278 + it("plain `pty run` (no -a) unchanged when nested: runs directly", async () => { 279 + const dir = makeSessionDir(); 280 + 281 + const r = runCliNested(dir, "outer-session", "run", "--", "true"); 282 + expect(r.stderr).toContain("Already inside pty session"); 283 + expect(r.stderr).toContain("running directly"); 284 + expect(r.status).toBe(0); 285 + }, 10000); 286 + });