···2233## Unreleased
4455+### Fixes
66+- **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:
77+ - `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:`).
88+ - 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.
99+ - 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.
1010+511### List & GC ergonomics
612- 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)
713- 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
···8181 "PTY_SESSION_DIR",
8282]);
83838484+/** Fallback TERM for child PTYs when no value was inherited. `xterm-256color`
8585+ * is the lowest common denominator every modern TUI knows how to drive; the
8686+ * kitty keyboard / modifyOtherKeys handshakes are dynamic CSI probes that
8787+ * work fine on top of it. Important specifically for daemons launched from
8888+ * a parent with a minimal env (launchd, systemd, cron, sparse CI runners) —
8989+ * those contexts drop TERM entirely, and a child without TERM causes many
9090+ * TUIs (Claude Code, vim, etc.) to fall back to legacy key encoding where
9191+ * Shift+Enter is indistinguishable from Enter. */
9292+const DEFAULT_CHILD_TERM = "xterm-256color";
9393+9494+/** Apply the TERM default in-place after the env has been assembled. Never
9595+ * overrides an explicit value — only fills in when it's absent. */
9696+function ensureChildTerm(env: Record<string, string>): void {
9797+ if (!env.TERM) env.TERM = DEFAULT_CHILD_TERM;
9898+}
9999+84100function buildChildEnv(options: ServerOptions): Record<string, string> {
85101 // Mutual exclusion: `env` (explicit, verbatim) can't be combined with the
86102 // allow-list-based `isolateEnv`/`extraEnv` path. If you want total control
···98114 if (options.env) {
99115 const env = { ...options.env };
100116 env.PTY_SESSION = options.name;
117117+ ensureChildTerm(env);
101118 return env;
102119 }
103120···108125 const env = { ...source };
109126 delete env.PTY_SERVER_CONFIG;
110127 env.PTY_SESSION = options.name;
128128+ ensureChildTerm(env);
111129 return env;
112130 }
113131···120138 for (const [k, v] of Object.entries(options.extraEnv)) env[k] = v;
121139 }
122140 env.PTY_SESSION = options.name;
141141+ ensureChildTerm(env);
123142 return env;
124143}
125144···429448 }
430449431450 try {
451451+ // NOTE: intentionally no `name:` option here — node-pty's `name`
452452+ // unconditionally clobbers env.TERM, which would hide any TERM the
453453+ // caller inherited or set explicitly. `buildChildEnv` guarantees
454454+ // childEnv.TERM is populated (defaulting to xterm-256color if absent),
455455+ // so node-pty will pick it up naturally. Was `name: "xterm-256color"`
456456+ // before; removing it lets inherited values like `xterm-kitty` flow
457457+ // through and lets TUIs negotiate the richer capabilities they allow.
432458 this.ptyProcess = pty.spawn(
433459 "/bin/sh",
434460 ["-c", 'exec "$@"', "sh", options.command, ...options.args],
435461 {
436436- name: "xterm-256color",
437462 cols: options.cols,
438463 rows: options.rows,
439464 cwd: options.cwd,
+10
src/supervisor.ts
···6161 start(): void {
6262 ensureSessionDir();
63636464+ // launchd starts us with a minimal env — no TERM, no COLORTERM. That
6565+ // propagates all the way down into each supervised session's child PTY,
6666+ // which makes TUIs like Claude Code fall back to legacy key encoding
6767+ // where Shift+Enter is indistinguishable from Enter. server.ts now
6868+ // defaults TERM at the PTY boundary, but we also seed it here so the
6969+ // supervisor's own process.env looks consistent (useful for any
7070+ // subprocess spawned outside the spawnDaemon path).
7171+ if (!process.env.TERM) process.env.TERM = "xterm-256color";
7272+ if (!process.env.COLORTERM) process.env.COLORTERM = "truecolor";
7373+6474 // Acquire lock to prevent multiple supervisors
6575 if (!acquireLock("supervisor")) {
6676 console.error("[supervisor] another supervisor is already running");
+100
tests/spawn-options.test.ts
···404404 expect(result.status).toBe(0);
405405 expect(result.stderr).not.toContain("uv_cwd");
406406 }, 15000);
407407+408408+ // Regression: when the daemon inherits a minimal env (launchd, systemd,
409409+ // sparse CI runners) the child pty ends up without TERM, and modern TUIs
410410+ // fall back to legacy key encoding where Shift+Enter is indistinguishable
411411+ // from Enter. Guarantee that the PTY boundary always provides a usable
412412+ // TERM, without clobbering an explicit one.
413413+ it("defaults TERM to xterm-256color in the child when env has no TERM", async () => {
414414+ const dir = makeSessionDir();
415415+ const name = uniqueName();
416416+ const dumpFile = path.join(dir, "term-default.txt");
417417+ process.env.PTY_SESSION_DIR = dir;
418418+419419+ await spawnDaemon({
420420+ name,
421421+ command: "/bin/sh",
422422+ args: ["-c", `env > ${JSON.stringify(dumpFile)}; exit 0`],
423423+ displayCommand: "env dump",
424424+ cwd: dir,
425425+ env: {
426426+ // Intentionally omit TERM. Minimal PATH only.
427427+ PATH: "/usr/bin:/bin",
428428+ },
429429+ });
430430+431431+ const start = Date.now();
432432+ while (Date.now() - start < 3000) {
433433+ try {
434434+ if (fs.existsSync(dumpFile) && fs.statSync(dumpFile).size > 0) break;
435435+ } catch {}
436436+ await new Promise((r) => setTimeout(r, 50));
437437+ }
438438+ const dumped = fs.readFileSync(dumpFile, "utf-8");
439439+ expect(dumped).toContain("TERM=xterm-256color");
440440+ }, 15000);
441441+442442+ it("preserves an explicit TERM from the verbatim env", async () => {
443443+ const dir = makeSessionDir();
444444+ const name = uniqueName();
445445+ const dumpFile = path.join(dir, "term-explicit.txt");
446446+ process.env.PTY_SESSION_DIR = dir;
447447+448448+ await spawnDaemon({
449449+ name,
450450+ command: "/bin/sh",
451451+ args: ["-c", `env > ${JSON.stringify(dumpFile)}; exit 0`],
452452+ displayCommand: "env dump",
453453+ cwd: dir,
454454+ env: {
455455+ PATH: "/usr/bin:/bin",
456456+ TERM: "xterm-kitty",
457457+ },
458458+ });
459459+460460+ const start = Date.now();
461461+ while (Date.now() - start < 3000) {
462462+ try {
463463+ if (fs.existsSync(dumpFile) && fs.statSync(dumpFile).size > 0) break;
464464+ } catch {}
465465+ await new Promise((r) => setTimeout(r, 50));
466466+ }
467467+ const dumped = fs.readFileSync(dumpFile, "utf-8");
468468+ expect(dumped).toContain("TERM=xterm-kitty");
469469+ expect(dumped).not.toContain("TERM=xterm-256color");
470470+ }, 15000);
471471+472472+ it("defaults TERM under --isolate-env too (TERM not inherited, must be supplied)", async () => {
473473+ const dir = makeSessionDir();
474474+ const name = uniqueName();
475475+ const dumpFile = `/tmp/pty-iso-term-${name}.txt`;
476476+477477+ // Strip TERM from the caller before invoking. --isolate-env then has
478478+ // nothing to inherit, and the default has to kick in. Explicit assertion
479479+ // that the scrubbed path still ends up with a usable TERM.
480480+ const callerEnv: Record<string, string> = {
481481+ ...process.env,
482482+ PTY_SESSION_DIR: dir,
483483+ };
484484+ delete callerEnv.TERM;
485485+486486+ const runResult = spawnSync(nodeBin, [
487487+ cliPath, "run", "-d", "--name", name, "--isolate-env",
488488+ "--", "sh", "-c", `env > ${JSON.stringify(dumpFile)}; sleep 30`,
489489+ ], {
490490+ env: callerEnv,
491491+ encoding: "utf-8",
492492+ timeout: 10000,
493493+ });
494494+ expect(runResult.status).toBe(0);
495495+496496+ await new Promise((r) => setTimeout(r, 500));
497497+ const dumped = fs.readFileSync(dumpFile, "utf-8");
498498+ expect(dumped).toContain("TERM=xterm-256color");
499499+500500+ const pidFile = path.join(dir, `${name}.pid`);
501501+ try {
502502+ const pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10);
503503+ bgPids.push(pid);
504504+ } catch {}
505505+ try { fs.unlinkSync(dumpFile); } catch {}
506506+ }, 15000);
407507});