This repository has no description
0

Configure Feed

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

Supervised processes get a better TERM

Nathan Herald (Apr 20, 2026, 12:44 PM +0200) a0e6fa8c 6eeead4c

+142 -1
+6
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### Fixes 6 + - **Shift+Enter / kitty keyboard protocol inside supervised (launchd-started) sessions.** Children of a daemon started from a minimal env (launchd, systemd, sparse CI runners) previously had no `TERM` at all, which caused modern TUIs — Claude Code, vim, nvim — to fall back to legacy key encoding where Shift+Enter is indistinguishable from Enter. Two related fixes: 7 + - `buildChildEnv` in `server.ts` now defaults `TERM=xterm-256color` in the child's env whenever it would otherwise be absent. Never overrides an explicit value. Covers all three env paths (legacy inheritance, `isolateEnv` allow-list, verbatim `env:`). 8 + - Removed the hardcoded `name: "xterm-256color"` from `pty.spawn()`. node-pty's `name` parameter unconditionally clobbers `env.TERM`, which meant a user running under `TERM=xterm-kitty` in their outer terminal never had that reach the inner child — the inner pty was always forced to `xterm-256color`. Now an inherited `TERM` (kitty, wezterm, iterm2) flows through naturally, letting TUIs negotiate the richer capabilities those terminals offer. 9 + - Supervisor also sets `TERM` / `COLORTERM` on its own `process.env` at startup as belt-and-suspenders, so any subprocess spawned outside the `spawnDaemon` path also sees a usable terminal type. 10 + 5 11 ### List & GC ergonomics 6 12 - Add `"vanished"` as a third session status alongside `"running"` and `"exited"`. A session becomes `vanished` when its daemon process is gone but no exit record was written (no `exitCode`, no `exitedAt`) — the shape caused by SIGKILL, OOM, or power-loss where the daemon had no chance to finalise metadata. `pty list` groups vanished sessions into their own warning-styled bucket (yellow header, `⚠` marker) so operators don't confuse them with clean exits, and `pty list --json` emits the literal string `"vanished"` in the `status` field. Same reapability as `exited` — `pty gc` cleans both up in one pass. The 24h `DEAD_SESSION_TTL` now also applies to vanished sessions (anchored on `createdAt` when `exitedAt` is absent), so they don't accumulate indefinitely on machines that regularly hard-crash. (part of #21) 7 13 - Add `pty list --summary` (and `--json --summary`). Replaces the per-session table with compact counts plus oldest/newest pointers, e.g. `7 sessions — 4 running, 2 exited, 1 vanished\noldest: claude-main (running, 2h12m)\nnewest: build-worker-3 (exited, 3m)`. Respects any filters in play (`--status`, `--older-than`, `--newer-than`, `--filter-tag`), so questions like "how many stale web workers do I have?" are one command. (part of #21)
+26 -1
src/server.ts
··· 81 81 "PTY_SESSION_DIR", 82 82 ]); 83 83 84 + /** Fallback TERM for child PTYs when no value was inherited. `xterm-256color` 85 + * is the lowest common denominator every modern TUI knows how to drive; the 86 + * kitty keyboard / modifyOtherKeys handshakes are dynamic CSI probes that 87 + * work fine on top of it. Important specifically for daemons launched from 88 + * a parent with a minimal env (launchd, systemd, cron, sparse CI runners) — 89 + * those contexts drop TERM entirely, and a child without TERM causes many 90 + * TUIs (Claude Code, vim, etc.) to fall back to legacy key encoding where 91 + * Shift+Enter is indistinguishable from Enter. */ 92 + const DEFAULT_CHILD_TERM = "xterm-256color"; 93 + 94 + /** Apply the TERM default in-place after the env has been assembled. Never 95 + * overrides an explicit value — only fills in when it's absent. */ 96 + function ensureChildTerm(env: Record<string, string>): void { 97 + if (!env.TERM) env.TERM = DEFAULT_CHILD_TERM; 98 + } 99 + 84 100 function buildChildEnv(options: ServerOptions): Record<string, string> { 85 101 // Mutual exclusion: `env` (explicit, verbatim) can't be combined with the 86 102 // allow-list-based `isolateEnv`/`extraEnv` path. If you want total control ··· 98 114 if (options.env) { 99 115 const env = { ...options.env }; 100 116 env.PTY_SESSION = options.name; 117 + ensureChildTerm(env); 101 118 return env; 102 119 } 103 120 ··· 108 125 const env = { ...source }; 109 126 delete env.PTY_SERVER_CONFIG; 110 127 env.PTY_SESSION = options.name; 128 + ensureChildTerm(env); 111 129 return env; 112 130 } 113 131 ··· 120 138 for (const [k, v] of Object.entries(options.extraEnv)) env[k] = v; 121 139 } 122 140 env.PTY_SESSION = options.name; 141 + ensureChildTerm(env); 123 142 return env; 124 143 } 125 144 ··· 429 448 } 430 449 431 450 try { 451 + // NOTE: intentionally no `name:` option here — node-pty's `name` 452 + // unconditionally clobbers env.TERM, which would hide any TERM the 453 + // caller inherited or set explicitly. `buildChildEnv` guarantees 454 + // childEnv.TERM is populated (defaulting to xterm-256color if absent), 455 + // so node-pty will pick it up naturally. Was `name: "xterm-256color"` 456 + // before; removing it lets inherited values like `xterm-kitty` flow 457 + // through and lets TUIs negotiate the richer capabilities they allow. 432 458 this.ptyProcess = pty.spawn( 433 459 "/bin/sh", 434 460 ["-c", 'exec "$@"', "sh", options.command, ...options.args], 435 461 { 436 - name: "xterm-256color", 437 462 cols: options.cols, 438 463 rows: options.rows, 439 464 cwd: options.cwd,
+10
src/supervisor.ts
··· 61 61 start(): void { 62 62 ensureSessionDir(); 63 63 64 + // launchd starts us with a minimal env — no TERM, no COLORTERM. That 65 + // propagates all the way down into each supervised session's child PTY, 66 + // which makes TUIs like Claude Code fall back to legacy key encoding 67 + // where Shift+Enter is indistinguishable from Enter. server.ts now 68 + // defaults TERM at the PTY boundary, but we also seed it here so the 69 + // supervisor's own process.env looks consistent (useful for any 70 + // subprocess spawned outside the spawnDaemon path). 71 + if (!process.env.TERM) process.env.TERM = "xterm-256color"; 72 + if (!process.env.COLORTERM) process.env.COLORTERM = "truecolor"; 73 + 64 74 // Acquire lock to prevent multiple supervisors 65 75 if (!acquireLock("supervisor")) { 66 76 console.error("[supervisor] another supervisor is already running");
+100
tests/spawn-options.test.ts
··· 404 404 expect(result.status).toBe(0); 405 405 expect(result.stderr).not.toContain("uv_cwd"); 406 406 }, 15000); 407 + 408 + // Regression: when the daemon inherits a minimal env (launchd, systemd, 409 + // sparse CI runners) the child pty ends up without TERM, and modern TUIs 410 + // fall back to legacy key encoding where Shift+Enter is indistinguishable 411 + // from Enter. Guarantee that the PTY boundary always provides a usable 412 + // TERM, without clobbering an explicit one. 413 + it("defaults TERM to xterm-256color in the child when env has no TERM", async () => { 414 + const dir = makeSessionDir(); 415 + const name = uniqueName(); 416 + const dumpFile = path.join(dir, "term-default.txt"); 417 + process.env.PTY_SESSION_DIR = dir; 418 + 419 + await spawnDaemon({ 420 + name, 421 + command: "/bin/sh", 422 + args: ["-c", `env > ${JSON.stringify(dumpFile)}; exit 0`], 423 + displayCommand: "env dump", 424 + cwd: dir, 425 + env: { 426 + // Intentionally omit TERM. Minimal PATH only. 427 + PATH: "/usr/bin:/bin", 428 + }, 429 + }); 430 + 431 + const start = Date.now(); 432 + while (Date.now() - start < 3000) { 433 + try { 434 + if (fs.existsSync(dumpFile) && fs.statSync(dumpFile).size > 0) break; 435 + } catch {} 436 + await new Promise((r) => setTimeout(r, 50)); 437 + } 438 + const dumped = fs.readFileSync(dumpFile, "utf-8"); 439 + expect(dumped).toContain("TERM=xterm-256color"); 440 + }, 15000); 441 + 442 + it("preserves an explicit TERM from the verbatim env", async () => { 443 + const dir = makeSessionDir(); 444 + const name = uniqueName(); 445 + const dumpFile = path.join(dir, "term-explicit.txt"); 446 + process.env.PTY_SESSION_DIR = dir; 447 + 448 + await spawnDaemon({ 449 + name, 450 + command: "/bin/sh", 451 + args: ["-c", `env > ${JSON.stringify(dumpFile)}; exit 0`], 452 + displayCommand: "env dump", 453 + cwd: dir, 454 + env: { 455 + PATH: "/usr/bin:/bin", 456 + TERM: "xterm-kitty", 457 + }, 458 + }); 459 + 460 + const start = Date.now(); 461 + while (Date.now() - start < 3000) { 462 + try { 463 + if (fs.existsSync(dumpFile) && fs.statSync(dumpFile).size > 0) break; 464 + } catch {} 465 + await new Promise((r) => setTimeout(r, 50)); 466 + } 467 + const dumped = fs.readFileSync(dumpFile, "utf-8"); 468 + expect(dumped).toContain("TERM=xterm-kitty"); 469 + expect(dumped).not.toContain("TERM=xterm-256color"); 470 + }, 15000); 471 + 472 + it("defaults TERM under --isolate-env too (TERM not inherited, must be supplied)", async () => { 473 + const dir = makeSessionDir(); 474 + const name = uniqueName(); 475 + const dumpFile = `/tmp/pty-iso-term-${name}.txt`; 476 + 477 + // Strip TERM from the caller before invoking. --isolate-env then has 478 + // nothing to inherit, and the default has to kick in. Explicit assertion 479 + // that the scrubbed path still ends up with a usable TERM. 480 + const callerEnv: Record<string, string> = { 481 + ...process.env, 482 + PTY_SESSION_DIR: dir, 483 + }; 484 + delete callerEnv.TERM; 485 + 486 + const runResult = spawnSync(nodeBin, [ 487 + cliPath, "run", "-d", "--name", name, "--isolate-env", 488 + "--", "sh", "-c", `env > ${JSON.stringify(dumpFile)}; sleep 30`, 489 + ], { 490 + env: callerEnv, 491 + encoding: "utf-8", 492 + timeout: 10000, 493 + }); 494 + expect(runResult.status).toBe(0); 495 + 496 + await new Promise((r) => setTimeout(r, 500)); 497 + const dumped = fs.readFileSync(dumpFile, "utf-8"); 498 + expect(dumped).toContain("TERM=xterm-256color"); 499 + 500 + const pidFile = path.join(dir, `${name}.pid`); 501 + try { 502 + const pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10); 503 + bgPids.push(pid); 504 + } catch {} 505 + try { fs.unlinkSync(dumpFile); } catch {} 506 + }, 15000); 407 507 });