This repository has no description
0

Configure Feed

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

list & gc ergonomics: vanished status, --summary/--status/age filters, gc --dry-run (closes #21)

Four changes, one PR:

1. Third session status `vanished` for the "daemon is gone, no exit
record written" case (SIGKILL / OOM / crash). Listed in its own
yellow-headered bucket with a warning icon so it doesn't blend into
cleanly-exited sessions. Reapable by `pty gc` like any other dead
session. TTL anchor falls back to createdAt so they can't accumulate
indefinitely.

2. `pty list --summary` (+ `--json --summary`) — compact counts and
oldest/newest pointers, respects all other filters.

3. `pty list --status <state>` and `--older-than/--newer-than <Ns|Nm|Nh|Nd>`
filters. Compose with `--filter-tag` and `--summary`. Grammar is
deliberately single-unit (no `1h30m`) to keep --help trivial.

4. `pty gc --dry-run` (`-n`) — preview what would be removed without
mutating anything. Covers exited AND vanished sessions AND orphan
`:l<pid>-<rand>` layout tags in one pass.

Client API:
- gc({ dryRun }), pruneOrphanLayoutTags({ dryRun })
- New `isGone(status)` helper — replaces hand-rolled
`status === "exited"` checks that should have included vanished all
along. Swept existing callers (run -a, peek fallback, pty up/down
cleanup, stats --all) to use it.
- New parseDuration/formatDuration exports for downstream tools.

Tests:
- duration.test.ts — full grammar coverage.
- list-filters.test.ts — vanished inference, --status, age filters,
--summary (text + json), filter composition.
- gc.test.ts — extended with --dry-run and vanished-session reaping.

726 → 728 local tests pass (+37 new), 0 failures.

Nathan Herald (Apr 19, 2026, 7:55 PM +0200) 6eeead4c 04c038a3

+835 -62
+10
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### List & GC ergonomics 6 + - 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 + - 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) 8 + - Add `pty list --status <running|exited|vanished>` to narrow output to a single status. (part of #21) 9 + - Add `pty list --older-than <dur>` / `--newer-than <dur>` age filters. Grammar: compact single-unit durations `Ns | Nm | Nh | Nd` (no compound forms like `1h30m`). Age is anchored on `exitedAt` when present, else `createdAt`. Composes with `--status` and `--filter-tag`. (part of #21) 10 + - Add `pty gc --dry-run` (`-n`). Walks the same set `gc` would clean up — exited sessions **plus** vanished sessions **plus** orphan `:l<pid>-<rand>` layout tags — and prints `Would remove:` / `Would prune:` lines without mutating anything. Ends with `Would clean up N stale sessions. (Dry run — no changes made.)`. (part of #21) 11 + - `gc()` client API now accepts an options object: `gc({ dryRun?: boolean }): Promise<string[]>`. Same for `pruneOrphanLayoutTags({ dryRun })`. Default behaviour unchanged. (part of #21) 12 + - Export `isGone(status)` helper from `@myobie/pty/client` — returns `true` for both `"exited"` and `"vanished"`. Use this in callers that mean "has metadata but no live daemon" (e.g. "reuse the cwd/tags for a respawn") rather than hand-rolling the two-branch check. (part of #21) 13 + - Export `parseDuration` and `formatDuration` from `@myobie/pty/client`. Downstream tools (pty-relay, pty-layout) can accept the same `Ns|Nm|Nh|Nd` grammar as `--older-than/--newer-than` without duplicating a parser. 14 + 5 15 ### Send 6 16 - Add `pty send --paste` (and `paste: true` on `SendOptions` / `SendDataOptions`) to wrap the entire payload in bracketed-paste markers (CSI 200 ~ … CSI 201 ~). The receiving TUI treats everything between the markers as one paste event rather than a sequence of keystrokes — intended for injecting multi-line prompts into agent sessions (claude, aider, etc.) without premature submission when the payload contains newlines. Works with positional text, `--seq` sequences, and composes with `--with-delay`. Flag position-independent. 7 17 - **Behaviour change:** `pty send` now errors on unknown trailing flags after positional text instead of silently dropping them. Previously `pty send <name> "text" --enter` exited 0 and sent only `"text"` — the `--enter` was swallowed and the text never executed because no Enter was appended. Now: exits non-zero with `Unexpected argument: --enter`. The common typos `--enter` / `--newline` / `--return` / `--cr` additionally print a hint pointing at the real syntax `--seq "<text>" --seq key:return`. Mirrors the strictness of the `--seq` parsing branch. (closes #20 — thanks @schickling-assistant)
+15 -5
docs/client.md
··· 31 31 32 32 Returns the Unix socket path for a session. 33 33 34 - ### `gc(): Promise<string[]>` 34 + ### `gc(opts?: { dryRun?: boolean }): Promise<string[]>` 35 35 36 - Remove all exited sessions. Returns the names of removed sessions. 36 + Remove all exited **and** vanished sessions. Returns the names of removed sessions. Pass `{ dryRun: true }` to walk the same set without deleting anything — useful for preview UIs. 37 37 38 38 ```typescript 39 39 const removed = await gc(); 40 40 console.log(`Cleaned up ${removed.length} sessions`); 41 + 42 + const would = await gc({ dryRun: true }); 43 + console.log(`Would clean up: ${would.join(", ")}`); 41 44 ``` 42 45 43 - ### `pruneOrphanLayoutTags(): Promise<PrunedTagResult[]>` 46 + ### `isGone(status): boolean` 47 + 48 + Semantic helper — returns `true` when `status` is `"exited"` or `"vanished"` (i.e. the session has metadata on disk but no live daemon). Use this in branches that mean "there's a record we might want to reuse" rather than the hand-rolled two-branch check. 49 + 50 + ### `pruneOrphanLayoutTags(opts?: { dryRun?: boolean }): Promise<PrunedTagResult[]>` 44 51 45 - Walks running sessions and removes tag keys of the form `:l<pid>-<rand>` whose encoded PID is no longer alive. `pty gc` calls this after removing exited sessions. 52 + Walks running sessions and removes tag keys of the form `:l<pid>-<rand>` whose encoded PID is no longer alive. `pty gc` calls this after removing exited sessions. Pass `{ dryRun: true }` to preview without mutating metadata. 46 53 47 54 ```typescript 48 55 interface PrunedTagResult { ··· 70 77 name: string; 71 78 socketPath: string; 72 79 pid: number | null; 73 - status: "running" | "exited"; 80 + // "running" — daemon alive, socket reachable. 81 + // "exited" — daemon wrote an exit record before shutting down. 82 + // "vanished" — daemon is gone with no exit record (SIGKILL / OOM / crash). 83 + status: "running" | "exited" | "vanished"; 74 84 metadata: SessionMetadata | null; 75 85 } 76 86
+231 -36
src/cli.ts
··· 11 11 getSession, 12 12 gc, 13 13 pruneOrphanLayoutTags, 14 + isGone, 14 15 cleanupAll, 15 16 cleanupSocket, 16 17 validateName, ··· 29 30 import { readPtyFile, type PtySessionDef } from "./ptyfile.ts"; 30 31 import { getSupervisorDir } from "./supervisor.ts"; 31 32 import { extractFilterTags as extractFilterTagsImpl, matchesAllTags, isReservedTagKey } from "./tags.ts"; 33 + import { parseDuration, formatDuration } from "./duration.ts"; 32 34 33 35 // Lazy-load the interactive TUI so non-interactive commands don't crash when 34 36 // the caller's cwd was deleted (the TUI module evaluates process.cwd() at load). ··· 530 532 case "ls": { 531 533 const listArgs = args.slice(); 532 534 const listFilterTags = extractFilterTags(listArgs); 533 - const jsonFlag = listArgs.includes("--json"); 534 - const tagsFlag = listArgs.includes("--tags"); 535 - const remoteFlag = listArgs.includes("--remote"); 536 - await cmdList(jsonFlag, tagsFlag, remoteFlag, listFilterTags); 535 + 536 + // Consume optional flag+value pairs (--status / --older-than / --newer-than) 537 + // in a single pass so they can appear in any order. Presence checks for 538 + // boolean flags happen after this loop. 539 + let statusFilter: "running" | "exited" | "vanished" | null = null; 540 + let olderThanMs: number | null = null; 541 + let newerThanMs: number | null = null; 542 + const consumed = new Set<number>(); 543 + for (let i = 1; i < listArgs.length; i++) { 544 + const arg = listArgs[i]; 545 + const val = listArgs[i + 1]; 546 + if (arg === "--status") { 547 + if (val !== "running" && val !== "exited" && val !== "vanished") { 548 + console.error(`--status expects one of: running, exited, vanished`); 549 + process.exit(1); 550 + } 551 + statusFilter = val; 552 + consumed.add(i); consumed.add(i + 1); 553 + i++; 554 + } else if (arg === "--older-than" || arg === "--newer-than") { 555 + const parsed = val == null ? null : parseDuration(val); 556 + if (parsed == null) { 557 + console.error(`${arg} expects a duration like 30s, 5m, 2h, 1d`); 558 + process.exit(1); 559 + } 560 + if (arg === "--older-than") olderThanMs = parsed; 561 + else newerThanMs = parsed; 562 + consumed.add(i); consumed.add(i + 1); 563 + i++; 564 + } 565 + } 566 + const remainingArgs = listArgs.filter((_, i) => !consumed.has(i)); 567 + 568 + const jsonFlag = remainingArgs.includes("--json"); 569 + const tagsFlag = remainingArgs.includes("--tags"); 570 + const remoteFlag = remainingArgs.includes("--remote"); 571 + const summaryFlag = remainingArgs.includes("--summary"); 572 + await cmdList({ 573 + json: jsonFlag, 574 + showTags: tagsFlag, 575 + remote: remoteFlag, 576 + filterTags: listFilterTags, 577 + statusFilter, 578 + olderThanMs, 579 + newerThanMs, 580 + summary: summaryFlag, 581 + }); 537 582 break; 538 583 } 539 584 ··· 579 624 } 580 625 581 626 case "gc": { 582 - await cmdGc(); 627 + const dryRun = args.slice(1).some((a) => a === "--dry-run" || a === "-n"); 628 + await cmdGc(dryRun); 583 629 break; 584 630 } 585 631 ··· 885 931 886 932 // Clean up any dead session with the same name, but preserve cwd and tags 887 933 // so that `run -a` re-creates the session in the original directory with original tags. 888 - const previousCwd = session?.status === "exited" ? session.metadata?.cwd : undefined; 889 - const previousTags = session?.status === "exited" ? session.metadata?.tags : undefined; 890 - if (session?.status === "exited") { 934 + const previousCwd = session && isGone(session.status) ? session.metadata?.cwd : undefined; 935 + const previousTags = session && isGone(session.status) ? session.metadata?.tags : undefined; 936 + if (session && isGone(session.status)) { 891 937 cleanupAll(name); 892 938 } 893 939 ··· 897 943 // If the session had a previous displayName (e.g., it was renamed before 898 944 // exiting), prefer preserving it over the fresh auto-generated label so 899 945 // `pty run -a` re-creates the session feeling-identical to the last one. 900 - const prevDisplayName = session?.status === "exited" ? session.metadata?.displayName : undefined; 946 + const prevDisplayName = session && isGone(session.status) ? session.metadata?.displayName : undefined; 901 947 const displayNameOpt = displayName ?? prevDisplayName; 902 948 await spawnDaemon({ 903 949 name, command, args, displayCommand, cwd: cwdOpt, ephemeral, tags: tagOpt, ··· 1096 1142 } 1097 1143 1098 1144 async function cmdPeek(name: string, follow: boolean, plain: boolean, full = false): Promise<void> { 1099 - // Check if session is exited — fall back to saved lastLines 1145 + // Dead daemon (cleanly exited or vanished) — fall back to saved lastLines. 1100 1146 const session = await getSession(name); 1101 - if (session?.status === "exited") { 1147 + if (session && isGone(session.status)) { 1102 1148 const meta = session.metadata; 1103 1149 if (meta?.lastLines && meta.lastLines.length > 0) { 1104 1150 process.stdout.write(meta.lastLines.join("\n") + "\n"); 1105 1151 } else { 1106 - console.error(`Session "${name}" has exited with no saved output.`); 1152 + const verb = session.status === "vanished" ? "vanished" : "exited"; 1153 + console.error(`Session "${name}" has ${verb} with no saved output.`); 1107 1154 } 1108 1155 return; 1109 1156 } ··· 1118 1165 }); 1119 1166 } 1120 1167 1121 - async function cmdList(json = false, showTags = false, remote = false, filterTags: Record<string, string> = {}): Promise<void> { 1168 + interface ListOptions { 1169 + json?: boolean; 1170 + showTags?: boolean; 1171 + remote?: boolean; 1172 + filterTags?: Record<string, string>; 1173 + statusFilter?: "running" | "exited" | "vanished" | null; 1174 + olderThanMs?: number | null; 1175 + newerThanMs?: number | null; 1176 + summary?: boolean; 1177 + } 1178 + 1179 + async function cmdList(opts: ListOptions = {}): Promise<void> { 1180 + const { 1181 + json = false, 1182 + showTags = false, 1183 + remote = false, 1184 + filterTags = {}, 1185 + statusFilter = null, 1186 + olderThanMs = null, 1187 + newerThanMs = null, 1188 + summary = false, 1189 + } = opts; 1190 + 1122 1191 let sessions = await listSessions(); 1123 1192 if (Object.keys(filterTags).length > 0) { 1124 1193 sessions = sessions.filter((s) => matchesAllTags(s.metadata?.tags, filterTags)); 1125 1194 } 1195 + if (statusFilter) { 1196 + sessions = sessions.filter((s) => s.status === statusFilter); 1197 + } 1198 + if (olderThanMs != null || newerThanMs != null) { 1199 + const now = Date.now(); 1200 + sessions = sessions.filter((s) => { 1201 + // Anchor age on exitedAt when available (true exit), else createdAt. 1202 + // Running sessions have no exit yet, so use createdAt. Missing 1203 + // metadata entirely means we can't filter on age — include by default. 1204 + const anchor = s.metadata?.exitedAt ?? s.metadata?.createdAt; 1205 + if (!anchor) return olderThanMs == null && newerThanMs == null; 1206 + const ageMs = now - new Date(anchor).getTime(); 1207 + if (olderThanMs != null && ageMs < olderThanMs) return false; 1208 + if (newerThanMs != null && ageMs > newerThanMs) return false; 1209 + return true; 1210 + }); 1211 + } 1126 1212 1127 1213 // Fetch relay hosts if --remote 1128 1214 let remoteHosts: { ··· 1147 1233 } catch {} 1148 1234 } 1149 1235 1236 + // Build the summary payload — used by both --json --summary and the 1237 + // human-facing --summary rendering below. Oldest/newest are picked from 1238 + // the filtered `sessions` so they match whatever the caller narrowed to. 1239 + const buildSummary = () => { 1240 + const byStatus: Record<string, number> = { 1241 + running: 0, exited: 0, vanished: 0, 1242 + }; 1243 + for (const s of sessions) byStatus[s.status] = (byStatus[s.status] ?? 0) + 1; 1244 + 1245 + let oldest: SessionInfo | null = null; 1246 + let newest: SessionInfo | null = null; 1247 + let oldestTs = Infinity; 1248 + let newestTs = -Infinity; 1249 + for (const s of sessions) { 1250 + const anchor = s.metadata?.createdAt; 1251 + if (!anchor) continue; 1252 + const ts = new Date(anchor).getTime(); 1253 + if (ts < oldestTs) { oldestTs = ts; oldest = s; } 1254 + if (ts > newestTs) { newestTs = ts; newest = s; } 1255 + } 1256 + const now = Date.now(); 1257 + const pickEndpoint = (s: SessionInfo | null, ts: number) => { 1258 + if (!s) return null; 1259 + return { 1260 + name: s.name, 1261 + status: s.status, 1262 + ageSeconds: Math.max(0, Math.floor((now - ts) / 1000)), 1263 + ...(s.metadata?.displayName ? { displayName: s.metadata.displayName } : {}), 1264 + }; 1265 + }; 1266 + return { 1267 + total: sessions.length, 1268 + byStatus, 1269 + oldest: pickEndpoint(oldest, oldestTs), 1270 + newest: pickEndpoint(newest, newestTs), 1271 + }; 1272 + }; 1273 + 1150 1274 if (json) { 1275 + if (summary) { 1276 + console.log(JSON.stringify(buildSummary())); 1277 + return; 1278 + } 1151 1279 const localOutput = sessions.map((s) => ({ 1152 1280 name: s.name, 1153 1281 status: s.status, ··· 1170 1298 return; 1171 1299 } 1172 1300 1301 + if (summary) { 1302 + const s = buildSummary(); 1303 + if (s.total === 0) { 1304 + console.log("No matching sessions."); 1305 + return; 1306 + } 1307 + const parts: string[] = []; 1308 + if (s.byStatus.running) parts.push(`${s.byStatus.running} running`); 1309 + if (s.byStatus.exited) parts.push(`${s.byStatus.exited} exited`); 1310 + if (s.byStatus.vanished) parts.push(`${s.byStatus.vanished} vanished`); 1311 + console.log(`${s.total} session${s.total === 1 ? "" : "s"} — ${parts.join(", ")}`); 1312 + const label = (e: { name: string; status: string; displayName?: string }) => 1313 + e.displayName ? `${e.displayName} (${e.name})` : e.name; 1314 + if (s.oldest) { 1315 + console.log(`oldest: ${label(s.oldest)} (${s.oldest.status}, ${formatDuration(s.oldest.ageSeconds * 1000)})`); 1316 + } 1317 + if (s.newest && (!s.oldest || s.newest.name !== s.oldest.name)) { 1318 + console.log(`newest: ${label(s.newest)} (${s.newest.status}, ${formatDuration(s.newest.ageSeconds * 1000)})`); 1319 + } 1320 + return; 1321 + } 1322 + 1173 1323 if (sessions.length === 0 && remoteHosts.length === 0) { 1174 1324 console.log("No active sessions."); 1175 1325 return; ··· 1177 1327 1178 1328 const running = sessions.filter((s) => s.status === "running"); 1179 1329 const exited = sessions.filter((s) => s.status === "exited"); 1330 + const vanished = sessions.filter((s) => s.status === "vanished"); 1180 1331 1181 1332 // Render tags as hashtags. When `showAll` is false, hide reserved keys 1182 1333 // (pty-internal bookkeeping like `ptyfile*`/`strategy`, plus any key ··· 1234 1385 } 1235 1386 } 1236 1387 1388 + if (vanished.length > 0) { 1389 + if (running.length > 0 || exited.length > 0) console.log(""); 1390 + // Dim-yellow header because vanished is a warning state — daemon was 1391 + // killed (SIGKILL / OOM / crash) without writing an exit record, so we 1392 + // can't say *why* it's gone, only that the socket stopped responding. 1393 + // `pty gc` cleans these up alongside normal exits. 1394 + console.log("\x1b[33mVanished sessions (no exit record — killed or crashed):\x1b[0m"); 1395 + for (const session of vanished) { 1396 + const meta = session.metadata; 1397 + const ago = meta?.createdAt ? timeAgo(new Date(meta.createdAt)) : "unknown"; 1398 + const cwd = meta?.cwd ? shortPath(meta.cwd) : ""; 1399 + const cmd = meta ? meta.displayCommand : ""; 1400 + const tagStr = renderTags(meta?.tags, showTags); 1401 + const marker = strategyMarker(meta?.tags); 1402 + const label = renderLabel(session, "\x1b[1;33m"); 1403 + console.log(` \u26a0 ${label}${marker}${tagStr} (vanished, started ${ago}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`); 1404 + } 1405 + } 1406 + 1237 1407 // Remote hosts — render with the same treatment as local: displayName 1238 1408 // in parens after the id, strategy marker, user-facing tags inline. 1239 1409 for (const host of remoteHosts) { ··· 1268 1438 console.error(`Session "${name}" not found.`); 1269 1439 process.exit(1); 1270 1440 } 1271 - if (session.status === "exited") { 1441 + if (isGone(session.status)) { 1272 1442 if (json) { 1273 1443 console.log(JSON.stringify({ 1274 1444 name: session.name, 1275 - status: "exited", 1445 + status: session.status, 1276 1446 exitCode: session.metadata?.exitCode ?? null, 1277 1447 exitedAt: session.metadata?.exitedAt ?? null, 1278 1448 ...(session.metadata?.tags ? { tags: session.metadata.tags } : {}), 1279 1449 })); 1450 + } else if (session.status === "vanished") { 1451 + console.log(`Session "${name}" has vanished (no exit record — killed or crashed).`); 1280 1452 } else { 1281 1453 const code = session.metadata?.exitCode ?? "?"; 1282 1454 console.log(`Session "${name}" has exited (code ${code}).`); ··· 1303 1475 // All sessions 1304 1476 const sessions = await listSessions(); 1305 1477 const running = sessions.filter((s) => s.status === "running"); 1306 - const exited = sessions.filter((s) => s.status === "exited"); 1478 + const gone = sessions.filter((s) => isGone(s.status)); 1307 1479 1308 - if (running.length === 0 && (!all || exited.length === 0)) { 1480 + if (running.length === 0 && (!all || gone.length === 0)) { 1309 1481 console.log("No running sessions."); 1310 1482 return; 1311 1483 } ··· 1326 1498 const output = [ 1327 1499 ...results.map((r) => r.stats ?? { name: r.session.name, error: r.error }), 1328 1500 ...(all 1329 - ? exited.map((s) => ({ 1501 + ? gone.map((s) => ({ 1330 1502 name: s.name, 1331 - status: "exited" as const, 1503 + status: s.status, 1332 1504 exitCode: s.metadata?.exitCode ?? null, 1333 1505 exitedAt: s.metadata?.exitedAt ?? null, 1334 1506 ...(s.metadata?.tags ? { tags: s.metadata.tags } : {}), ··· 1350 1522 if (i < results.length - 1) console.log(""); 1351 1523 } 1352 1524 1353 - if (all && exited.length > 0) { 1525 + if (all && gone.length > 0) { 1354 1526 if (results.length > 0) console.log(""); 1355 - console.log("Exited sessions:"); 1356 - for (const s of exited) { 1357 - const code = s.metadata?.exitCode ?? "?"; 1358 - const ago = s.metadata?.exitedAt ? timeAgo(new Date(s.metadata.exitedAt)) : "unknown"; 1359 - console.log(` ${s.name} (exited with code ${code}, ${ago})`); 1527 + const exited = gone.filter((s) => s.status === "exited"); 1528 + const vanished = gone.filter((s) => s.status === "vanished"); 1529 + if (exited.length > 0) { 1530 + console.log("Exited sessions:"); 1531 + for (const s of exited) { 1532 + const code = s.metadata?.exitCode ?? "?"; 1533 + const ago = s.metadata?.exitedAt ? timeAgo(new Date(s.metadata.exitedAt)) : "unknown"; 1534 + console.log(` ${s.name} (exited with code ${code}, ${ago})`); 1535 + } 1536 + } 1537 + if (vanished.length > 0) { 1538 + if (exited.length > 0) console.log(""); 1539 + console.log("Vanished sessions (no exit record):"); 1540 + for (const s of vanished) { 1541 + const ago = s.metadata?.createdAt ? timeAgo(new Date(s.metadata.createdAt)) : "unknown"; 1542 + console.log(` \u26a0 ${s.name} (started ${ago})`); 1543 + } 1360 1544 } 1361 1545 } 1362 1546 } ··· 1602 1786 console.log(`Session "${name}" removed.`); 1603 1787 } 1604 1788 1605 - async function cmdGc(): Promise<void> { 1606 - const removed = await gc(); 1607 - const prunedTags = await pruneOrphanLayoutTags(); 1789 + async function cmdGc(dryRun: boolean): Promise<void> { 1790 + const removed = await gc({ dryRun }); 1791 + const prunedTags = await pruneOrphanLayoutTags({ dryRun }); 1792 + 1793 + const verb = dryRun ? "Would remove" : "Removed"; 1794 + const prunedVerb = dryRun ? "Would prune" : "Pruned"; 1608 1795 1609 1796 for (const name of removed) { 1610 - console.log(`Removed: ${name}`); 1797 + console.log(`${verb}: ${name}`); 1611 1798 } 1612 1799 for (const { name, removedKeys } of prunedTags) { 1613 - console.log(`Pruned orphan tags on ${name}: ${removedKeys.map((k) => `#${k}`).join(" ")}`); 1800 + console.log( 1801 + `${prunedVerb} orphan tags on ${name}: ${removedKeys.map((k) => `#${k}`).join(" ")}`, 1802 + ); 1614 1803 } 1615 1804 1616 1805 const totalTags = prunedTags.reduce((sum, r) => sum + r.removedKeys.length, 0); 1617 1806 if (removed.length === 0 && totalTags === 0) { 1618 - console.log("Nothing to clean up."); 1807 + console.log(dryRun ? "Nothing would be cleaned up." : "Nothing to clean up."); 1619 1808 return; 1620 1809 } 1621 1810 1622 1811 const parts: string[] = []; 1623 1812 if (removed.length > 0) { 1624 - parts.push(`${removed.length} exited session${removed.length === 1 ? "" : "s"}`); 1813 + parts.push(`${removed.length} stale session${removed.length === 1 ? "" : "s"}`); 1625 1814 } 1626 1815 if (totalTags > 0) { 1627 1816 parts.push(`${totalTags} orphan tag${totalTags === 1 ? "" : "s"}`); 1628 1817 } 1629 - console.log(`Cleaned up ${parts.join(" and ")}.`); 1818 + console.log( 1819 + dryRun 1820 + ? `Would clean up ${parts.join(" and ")}. (Dry run — no changes made.)` 1821 + : `Cleaned up ${parts.join(" and ")}.`, 1822 + ); 1630 1823 } 1631 1824 1632 1825 async function cmdSupervisorStart(): Promise<void> { ··· 2113 2306 continue; 2114 2307 } 2115 2308 2116 - // Clean up exited session with the same name 2309 + // Clean up any stale session (exited or vanished) with the same name 2310 + // so the respawn can reuse the slot. 2117 2311 const existingSession = existing.find((s) => s.name === sess.name); 2118 - if (existingSession?.status === "exited") { 2312 + if (existingSession && isGone(existingSession.status)) { 2119 2313 cleanupAll(sess.name); 2120 2314 } 2121 2315 ··· 2183 2377 console.error(` ✗ ${sess.name}: failed to stop`); 2184 2378 } 2185 2379 cleanupSocket(sess.name); 2186 - } else if (existingSession.status === "exited") { 2380 + } else if (isGone(existingSession.status)) { 2187 2381 cleanupAll(sess.name); 2188 2382 console.log(` ○ ${sess.name} (cleaned up)`); 2189 2383 stopped++; ··· 2390 2584 const days = Math.floor(hours / 24); 2391 2585 return `${days}d ago`; 2392 2586 } 2587 + 2393 2588 2394 2589 // ── Wrap/Unwrap ── 2395 2590
+5 -1
src/client-api.ts
··· 3 3 4 4 // Session management 5 5 export { 6 - listSessions, getSession, gc, pruneOrphanLayoutTags, 6 + listSessions, getSession, gc, pruneOrphanLayoutTags, isGone, 7 7 validateName, updateTags, setDisplayName, 8 8 getSessionDir, getSocketPath, 9 9 cleanupSocket, cleanupAll, ··· 45 45 46 46 // Tag filter helpers (used by --filter-tag; shared with pty-relay) 47 47 export { extractFilterTags, matchesAllTags, isReservedTagKey } from "./tags.ts"; 48 + 49 + // Duration parse/format — used by `pty list --older-than/--newer-than`, 50 + // available here so downstream tools can accept the same grammar. 51 + export { parseDuration, formatDuration } from "./duration.ts"; 48 52 49 53 // Keys 50 54 export { resolveKey, parseSeqValue } from "./keys.ts";
+43
src/duration.ts
··· 1 + /** 2 + * Duration utilities shared between the CLI and tests. 3 + * 4 + * `parseDuration` accepts compact `Ns|Nm|Nh|Nd` strings (e.g. `30s`, `5m`, 5 + * `2h`, `7d`) and returns milliseconds. It is intentionally strict — no 6 + * compound forms like `1h30m` — so the grammar stays trivial to document in 7 + * `pty --help` and unambiguous in scripts. 8 + * 9 + * `formatDuration` renders a millisecond value back into a compact string 10 + * (`45s`, `2h12m`, `3d2h`) for display. 11 + */ 12 + 13 + export function parseDuration(input: string): number | null { 14 + const m = /^(\d+)\s*(s|m|h|d)$/i.exec(input.trim()); 15 + if (!m) return null; 16 + const n = parseInt(m[1], 10); 17 + if (!Number.isFinite(n) || n < 0) return null; 18 + const unitMs: Record<string, number> = { 19 + s: 1000, 20 + m: 60 * 1000, 21 + h: 60 * 60 * 1000, 22 + d: 24 * 60 * 60 * 1000, 23 + }; 24 + return n * unitMs[m[2].toLowerCase()]; 25 + } 26 + 27 + export function formatDuration(ms: number): string { 28 + const seconds = Math.max(0, Math.floor(ms / 1000)); 29 + if (seconds < 60) return `${seconds}s`; 30 + const minutes = Math.floor(seconds / 60); 31 + if (minutes < 60) { 32 + const s = seconds % 60; 33 + return s === 0 ? `${minutes}m` : `${minutes}m${s}s`; 34 + } 35 + const hours = Math.floor(minutes / 60); 36 + if (hours < 24) { 37 + const m = minutes % 60; 38 + return m === 0 ? `${hours}h` : `${hours}h${m}m`; 39 + } 40 + const days = Math.floor(hours / 24); 41 + const h = hours % 24; 42 + return h === 0 ? `${days}d` : `${days}d${h}h`; 43 + }
+54 -19
src/sessions.ts
··· 86 86 name: string; 87 87 socketPath: string; 88 88 pid: number | null; 89 - status: "running" | "exited"; 89 + /** 90 + * `running` — daemon process is alive and its socket is reachable. 91 + * `exited` — daemon wrote an exit record (`exitCode` / `exitedAt`) before 92 + * shutting down; we know how it ended. 93 + * `vanished` — the daemon process is gone but no exit record was written. 94 + * Most commonly caused by SIGKILL / OOM / power-loss, where the 95 + * daemon had no chance to finalise metadata. Same reapability 96 + * as `exited` (still cleaned up by `pty gc`), but the exit 97 + * details are forever unknown. 98 + */ 99 + status: "running" | "exited" | "vanished"; 90 100 metadata: SessionMetadata | null; 101 + } 102 + 103 + /** Semantic helper: session has metadata but no live daemon (either `exited` 104 + * or `vanished`). Use this wherever the branch is "there's a record and we 105 + * might want to re-use cwd/tags/displayName"; reserve `=== "exited"` for 106 + * branches that specifically care about clean-exit details. */ 107 + export function isGone(status: SessionInfo["status"]): boolean { 108 + return status === "exited" || status === "vanished"; 91 109 } 92 110 93 111 export function writeMetadata(name: string, metadata: SessionMetadata): void { ··· 196 214 continue; 197 215 } 198 216 199 - // Auto-clean dead sessions older than 24h 200 - if (metadata.exitedAt) { 201 - const exitedAt = new Date(metadata.exitedAt).getTime(); 202 - if (Date.now() - exitedAt > DEAD_SESSION_TTL_MS) { 217 + // Auto-clean dead sessions older than 24h. For cleanly-exited sessions 218 + // this keys off exitedAt; for vanished sessions (no exit record written) 219 + // fall back to createdAt so they don't accumulate indefinitely. A session 220 + // with a missing daemon and a metadata file older than 24h is not coming 221 + // back regardless of why it died. 222 + const ageAnchor = metadata.exitedAt ?? metadata.createdAt; 223 + if (ageAnchor) { 224 + const anchoredAt = new Date(ageAnchor).getTime(); 225 + if (Date.now() - anchoredAt > DEAD_SESSION_TTL_MS) { 203 226 cleanupAll(name); 204 227 continue; 205 228 } 206 229 } 230 + 231 + // Vanished = dead daemon with no exit record. SIGKILL / OOM / crash. 232 + const vanished = 233 + metadata.exitedAt == null && metadata.exitCode == null; 207 234 208 235 sessions.push({ 209 236 name, 210 237 socketPath: getSocketPath(name), 211 238 pid: null, 212 - status: "exited", 239 + status: vanished ? "vanished" : "exited", 213 240 metadata, 214 241 }); 215 242 } ··· 240 267 return refs; 241 268 } 242 269 243 - /** Remove all exited sessions. Returns the names of removed sessions. */ 244 - export async function gc(): Promise<string[]> { 270 + /** Remove all exited **and** vanished sessions. Returns the names of removed 271 + * sessions. `dryRun: true` performs the same walk but doesn't delete — useful 272 + * for preview UIs. */ 273 + export async function gc(opts: { dryRun?: boolean } = {}): Promise<string[]> { 245 274 const sessions = await listSessions(); 246 - const exited = sessions.filter((s) => s.status === "exited"); 247 - for (const s of exited) { 248 - cleanupAll(s.name); 275 + const gone = sessions.filter((s) => isGone(s.status)); 276 + if (!opts.dryRun) { 277 + for (const s of gone) cleanupAll(s.name); 249 278 } 250 - return exited.map((s) => s.name); 279 + return gone.map((s) => s.name); 251 280 } 252 281 253 282 /** ··· 269 298 * pty-layout process that exited without clearing its tags. 270 299 * 271 300 * Returns a list of sessions that had at least one tag pruned, and 272 - * which keys were removed from each. 301 + * which keys were removed from each. `dryRun: true` performs the same 302 + * walk but doesn't call `updateTags`. 273 303 */ 274 - export async function pruneOrphanLayoutTags(): Promise<PrunedTagResult[]> { 304 + export async function pruneOrphanLayoutTags( 305 + opts: { dryRun?: boolean } = {}, 306 + ): Promise<PrunedTagResult[]> { 275 307 const sessions = await listSessions(); 276 308 const results: PrunedTagResult[] = []; 277 309 for (const s of sessions) { ··· 290 322 if (!isProcessAlive(pid)) toRemove.push(key); 291 323 } 292 324 if (toRemove.length === 0) continue; 293 - try { 294 - updateTags(s.name, {}, toRemove); 295 - results.push({ name: s.name, removedKeys: toRemove }); 296 - } catch { 297 - // Session metadata vanished between listing and update — ignore. 325 + if (!opts.dryRun) { 326 + try { 327 + updateTags(s.name, {}, toRemove); 328 + } catch { 329 + // Session metadata disappeared between listing and update — ignore. 330 + continue; 331 + } 298 332 } 333 + results.push({ name: s.name, removedKeys: toRemove }); 299 334 } 300 335 return results; 301 336 }
+3 -1
src/tui/interactive.ts
··· 422 422 return true; 423 423 } 424 424 if (item.session) { 425 - if (item.session.status === "exited") { 425 + // Both `exited` (clean) and `vanished` (killed) are dead daemons 426 + // that can be restarted with the same metadata. 427 + if (item.session.status !== "running") { 426 428 doRestart(item.session); 427 429 } else { 428 430 doAttach(item.session.name);
+88
tests/duration.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { parseDuration, formatDuration } from "../src/duration.ts"; 3 + 4 + describe("parseDuration", () => { 5 + it("parses seconds", () => { 6 + expect(parseDuration("30s")).toBe(30_000); 7 + expect(parseDuration("1s")).toBe(1000); 8 + expect(parseDuration("0s")).toBe(0); 9 + }); 10 + 11 + it("parses minutes", () => { 12 + expect(parseDuration("5m")).toBe(5 * 60_000); 13 + expect(parseDuration("1m")).toBe(60_000); 14 + }); 15 + 16 + it("parses hours", () => { 17 + expect(parseDuration("2h")).toBe(2 * 3600_000); 18 + }); 19 + 20 + it("parses days", () => { 21 + expect(parseDuration("7d")).toBe(7 * 86_400_000); 22 + }); 23 + 24 + it("is case-insensitive on the unit", () => { 25 + expect(parseDuration("2H")).toBe(2 * 3600_000); 26 + expect(parseDuration("30S")).toBe(30_000); 27 + }); 28 + 29 + it("tolerates internal whitespace and leading/trailing spaces", () => { 30 + expect(parseDuration(" 5m ")).toBe(5 * 60_000); 31 + expect(parseDuration("5 m")).toBe(5 * 60_000); 32 + }); 33 + 34 + it("rejects compound forms", () => { 35 + // Keep grammar strict — `--older-than 1h30m` would be ambiguous 36 + // without documentation, so we only accept single-unit durations. 37 + expect(parseDuration("1h30m")).toBeNull(); 38 + expect(parseDuration("1h 30m")).toBeNull(); 39 + }); 40 + 41 + it("rejects missing unit or missing number", () => { 42 + expect(parseDuration("5")).toBeNull(); 43 + expect(parseDuration("s")).toBeNull(); 44 + expect(parseDuration("")).toBeNull(); 45 + }); 46 + 47 + it("rejects unknown units", () => { 48 + expect(parseDuration("5y")).toBeNull(); // no years 49 + expect(parseDuration("5w")).toBeNull(); // no weeks 50 + expect(parseDuration("5ms")).toBeNull(); // no milliseconds 51 + }); 52 + 53 + it("rejects negative and non-integer numbers", () => { 54 + expect(parseDuration("-5m")).toBeNull(); 55 + expect(parseDuration("1.5h")).toBeNull(); 56 + }); 57 + }); 58 + 59 + describe("formatDuration", () => { 60 + it("renders sub-minute durations in seconds", () => { 61 + expect(formatDuration(0)).toBe("0s"); 62 + expect(formatDuration(1000)).toBe("1s"); 63 + expect(formatDuration(45_000)).toBe("45s"); 64 + expect(formatDuration(59_999)).toBe("59s"); 65 + }); 66 + 67 + it("renders sub-hour durations in minutes, dropping trailing :00", () => { 68 + expect(formatDuration(60_000)).toBe("1m"); 69 + expect(formatDuration(65_000)).toBe("1m5s"); 70 + expect(formatDuration(30 * 60_000)).toBe("30m"); 71 + }); 72 + 73 + it("renders sub-day durations in hours, dropping trailing :00", () => { 74 + expect(formatDuration(3600_000)).toBe("1h"); 75 + expect(formatDuration(3600_000 + 12 * 60_000)).toBe("1h12m"); 76 + expect(formatDuration(23 * 3600_000)).toBe("23h"); 77 + }); 78 + 79 + it("renders multi-day durations in days+hours", () => { 80 + expect(formatDuration(86_400_000)).toBe("1d"); 81 + expect(formatDuration(86_400_000 + 2 * 3600_000)).toBe("1d2h"); 82 + expect(formatDuration(3 * 86_400_000)).toBe("3d"); 83 + }); 84 + 85 + it("treats negative values as 0", () => { 86 + expect(formatDuration(-1000)).toBe("0s"); 87 + }); 88 + });
+78
tests/gc.test.ts
··· 185 185 expect(result.status).toBe(0); 186 186 expect(result.stdout).toContain("Nothing to clean up."); 187 187 }, 15000); 188 + 189 + it("--dry-run previews exited-session removal without deleting", async () => { 190 + const dir = makeSessionDir(); 191 + const name = uniqueName(); 192 + await startDaemon(dir, name, "true"); 193 + await new Promise((r) => setTimeout(r, 1000)); 194 + 195 + expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(true); 196 + 197 + const dry = runCli(dir, "gc", "--dry-run"); 198 + expect(dry.status).toBe(0); 199 + expect(dry.stdout).toContain(`Would remove: ${name}`); 200 + expect(dry.stdout).toContain("Dry run"); 201 + // Metadata still on disk after dry-run. 202 + expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(true); 203 + 204 + // And the real gc then actually removes it. 205 + const real = runCli(dir, "gc"); 206 + expect(real.status).toBe(0); 207 + expect(real.stdout).toContain(`Removed: ${name}`); 208 + expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(false); 209 + }, 15000); 210 + 211 + it("--dry-run previews orphan tag pruning without mutating metadata", async () => { 212 + const dir = makeSessionDir(); 213 + const name = uniqueName(); 214 + const deadPid = findDeadPid(); 215 + const tagKey = `:l${deadPid}-abc`; 216 + 217 + await startDaemon(dir, name, "cat", [], { [tagKey]: "1" }); 218 + expect(readMeta(dir, name).tags[tagKey]).toBe("1"); 219 + 220 + const dry = runCli(dir, "gc", "--dry-run"); 221 + expect(dry.status).toBe(0); 222 + expect(dry.stdout).toContain(`Would prune orphan tags on ${name}: #${tagKey}`); 223 + // Tag is still there after dry-run. 224 + expect(readMeta(dir, name).tags[tagKey]).toBe("1"); 225 + 226 + // And a real gc then actually removes it. 227 + const real = runCli(dir, "gc"); 228 + expect(real.status).toBe(0); 229 + expect(real.stdout).toContain(`Pruned orphan tags on ${name}: #${tagKey}`); 230 + // The pruned key was the only tag on the session, so `tags` is cleared 231 + // entirely by updateTags — either form proves the orphan is gone. 232 + expect(readMeta(dir, name).tags?.[tagKey]).toBeUndefined(); 233 + }, 15000); 234 + 235 + it("-n is accepted as an alias for --dry-run", async () => { 236 + const dir = makeSessionDir(); 237 + const name = uniqueName(); 238 + await startDaemon(dir, name, "true"); 239 + await new Promise((r) => setTimeout(r, 1000)); 240 + 241 + const result = runCli(dir, "gc", "-n"); 242 + expect(result.status).toBe(0); 243 + expect(result.stdout).toContain(`Would remove: ${name}`); 244 + expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(true); 245 + }, 15000); 246 + 247 + it("reaps vanished sessions (dead PID, no exit record)", () => { 248 + const dir = makeSessionDir(); 249 + const name = uniqueName(); 250 + // Simulate a vanished session: metadata file with no exitedAt/exitCode 251 + // and no pid/sock files. listSessions will infer status=vanished. 252 + const metaPath = path.join(dir, `${name}.json`); 253 + fs.writeFileSync(metaPath, JSON.stringify({ 254 + command: "cat", 255 + args: [], 256 + displayCommand: "cat", 257 + cwd: os.tmpdir(), 258 + createdAt: new Date().toISOString(), 259 + })); 260 + 261 + const result = runCli(dir, "gc"); 262 + expect(result.status).toBe(0); 263 + expect(result.stdout).toContain(`Removed: ${name}`); 264 + expect(fs.existsSync(metaPath)).toBe(false); 265 + }, 10000); 188 266 });
+308
tests/list-filters.test.ts
··· 1 + // End-to-end tests for the new `pty list` flags shipped alongside the 2 + // vanished-status change (--status, --older-than/--newer-than, --summary) 3 + // and for the status inference itself. Writes metadata files directly to 4 + // a scratch session dir for the age-filter and vanished cases so tests 5 + // don't have to wait for wall-clock time. 6 + 7 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 8 + import * as fs from "node:fs"; 9 + import * as os from "node:os"; 10 + import * as path from "node:path"; 11 + import { fileURLToPath } from "node:url"; 12 + import { spawn, spawnSync } from "node:child_process"; 13 + 14 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 15 + const nodeBin = process.execPath; 16 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 17 + const serverModule = path.join(__dirname, "..", "dist", "server.js"); 18 + 19 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-lf-")); 20 + afterAll(() => { 21 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 22 + }); 23 + 24 + let bgPids: number[] = []; 25 + let sessionDirs: string[] = []; 26 + 27 + function makeSessionDir(): string { 28 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 29 + sessionDirs.push(dir); 30 + return dir; 31 + } 32 + 33 + let nameCounter = 0; 34 + function uniqueName(): string { 35 + return `lf${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; 36 + } 37 + 38 + async function startDaemon( 39 + sessionDir: string, 40 + name: string, 41 + command: string, 42 + args: string[] = [], 43 + ): Promise<number> { 44 + const config = JSON.stringify({ 45 + name, command, args, displayCommand: command, 46 + cwd: os.tmpdir(), rows: 24, cols: 80, 47 + }); 48 + const child = spawn(nodeBin, [serverModule], { 49 + detached: true, 50 + stdio: ["ignore", "ignore", "pipe"], 51 + env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, 52 + }); 53 + let stderr = ""; 54 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 55 + let exitCode: number | null = null; 56 + child.on("exit", (code) => { exitCode = code; }); 57 + (child.stderr as any)?.unref?.(); 58 + child.unref(); 59 + 60 + const socketPath = path.join(sessionDir, `${name}.sock`); 61 + const start = Date.now(); 62 + while (Date.now() - start < 5000) { 63 + if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); 64 + try { 65 + fs.statSync(socketPath); 66 + await new Promise((r) => setTimeout(r, 100)); 67 + bgPids.push(child.pid!); 68 + return child.pid!; 69 + } catch {} 70 + await new Promise((r) => setTimeout(r, 50)); 71 + } 72 + throw new Error("Timeout waiting for daemon"); 73 + } 74 + 75 + /** Fabricate a session-directory-only record with no daemon. Lets us test 76 + * age filters and the vanished inference without waiting for real wall time 77 + * or SIGKILL-ing a real daemon. */ 78 + function writeFakeMetadata(dir: string, name: string, opts: { 79 + createdAt: string; 80 + exitedAt?: string; 81 + exitCode?: number; 82 + tags?: Record<string, string>; 83 + }) { 84 + const meta = { 85 + command: "cat", 86 + args: [], 87 + displayCommand: "cat", 88 + cwd: os.tmpdir(), 89 + createdAt: opts.createdAt, 90 + ...(opts.exitedAt ? { exitedAt: opts.exitedAt } : {}), 91 + ...(opts.exitCode != null ? { exitCode: opts.exitCode } : {}), 92 + ...(opts.tags ? { tags: opts.tags } : {}), 93 + }; 94 + fs.writeFileSync(path.join(dir, `${name}.json`), JSON.stringify(meta)); 95 + } 96 + 97 + function runCli(sessionDir: string, ...args: string[]) { 98 + return spawnSync(nodeBin, [cliPath, ...args], { 99 + env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 100 + encoding: "utf-8", 101 + timeout: 10000, 102 + }); 103 + } 104 + 105 + afterEach(() => { 106 + for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 107 + bgPids = []; 108 + for (const dir of sessionDirs) { 109 + try { 110 + for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 111 + } catch {} 112 + } 113 + sessionDirs = []; 114 + }); 115 + 116 + describe("vanished status", () => { 117 + it("listSessions infers vanished when metadata has no exitedAt/exitCode and no live daemon", () => { 118 + const dir = makeSessionDir(); 119 + const name = uniqueName(); 120 + writeFakeMetadata(dir, name, { createdAt: new Date().toISOString() }); 121 + 122 + const r = runCli(dir, "list", "--json"); 123 + expect(r.status).toBe(0); 124 + const sessions = JSON.parse(r.stdout); 125 + const found = sessions.find((s: any) => s.name === name); 126 + expect(found).toBeDefined(); 127 + expect(found.status).toBe("vanished"); 128 + expect(found.exitCode).toBeNull(); 129 + expect(found.exitedAt).toBeNull(); 130 + }, 10000); 131 + 132 + it("text output separates vanished sessions into their own bucket", () => { 133 + const dir = makeSessionDir(); 134 + const name = uniqueName(); 135 + writeFakeMetadata(dir, name, { createdAt: new Date().toISOString() }); 136 + 137 + const r = runCli(dir, "list"); 138 + expect(r.status).toBe(0); 139 + expect(r.stdout).toContain("Vanished sessions"); 140 + expect(r.stdout).toContain(name); 141 + }, 10000); 142 + 143 + it("cleanly-exited sessions keep status=exited, not vanished", async () => { 144 + const dir = makeSessionDir(); 145 + const name = uniqueName(); 146 + await startDaemon(dir, name, "true"); // exits cleanly 147 + await new Promise((r) => setTimeout(r, 1000)); 148 + 149 + const r = runCli(dir, "list", "--json"); 150 + const sessions = JSON.parse(r.stdout); 151 + const found = sessions.find((s: any) => s.name === name); 152 + expect(found.status).toBe("exited"); 153 + expect(found.exitCode).toBe(0); 154 + expect(found.exitedAt).not.toBeNull(); 155 + }, 15000); 156 + }); 157 + 158 + describe("pty list --status", () => { 159 + it("filters to a single status", async () => { 160 + const dir = makeSessionDir(); 161 + const live = uniqueName(); 162 + await startDaemon(dir, live, "cat"); 163 + 164 + const gone = uniqueName(); 165 + writeFakeMetadata(dir, gone, { 166 + createdAt: new Date().toISOString(), 167 + exitedAt: new Date().toISOString(), 168 + exitCode: 0, 169 + }); 170 + 171 + const lost = uniqueName(); 172 + writeFakeMetadata(dir, lost, { createdAt: new Date().toISOString() }); 173 + 174 + const onlyRunning = runCli(dir, "list", "--json", "--status", "running"); 175 + expect(JSON.parse(onlyRunning.stdout).map((s: any) => s.name)).toEqual([live]); 176 + 177 + const onlyExited = runCli(dir, "list", "--json", "--status", "exited"); 178 + expect(JSON.parse(onlyExited.stdout).map((s: any) => s.name)).toEqual([gone]); 179 + 180 + const onlyVanished = runCli(dir, "list", "--json", "--status", "vanished"); 181 + expect(JSON.parse(onlyVanished.stdout).map((s: any) => s.name)).toEqual([lost]); 182 + }, 15000); 183 + 184 + it("rejects an invalid --status value", () => { 185 + const dir = makeSessionDir(); 186 + const r = runCli(dir, "list", "--status", "bogus"); 187 + expect(r.status).not.toBe(0); 188 + expect(r.stderr).toContain("--status expects"); 189 + }, 10000); 190 + }); 191 + 192 + describe("pty list --older-than / --newer-than", () => { 193 + it("--older-than filters out recent sessions", () => { 194 + const dir = makeSessionDir(); 195 + const old = uniqueName(); 196 + const recent = uniqueName(); 197 + const TWO_HOURS_AGO = new Date(Date.now() - 2 * 3600_000).toISOString(); 198 + writeFakeMetadata(dir, old, { createdAt: TWO_HOURS_AGO }); 199 + writeFakeMetadata(dir, recent, { createdAt: new Date().toISOString() }); 200 + 201 + const r = runCli(dir, "list", "--json", "--older-than", "1h"); 202 + const names = JSON.parse(r.stdout).map((s: any) => s.name); 203 + expect(names).toEqual([old]); 204 + }, 10000); 205 + 206 + it("--newer-than filters out old sessions", () => { 207 + const dir = makeSessionDir(); 208 + const old = uniqueName(); 209 + const recent = uniqueName(); 210 + writeFakeMetadata(dir, old, { 211 + createdAt: new Date(Date.now() - 2 * 3600_000).toISOString(), 212 + }); 213 + writeFakeMetadata(dir, recent, { createdAt: new Date().toISOString() }); 214 + 215 + const r = runCli(dir, "list", "--json", "--newer-than", "1h"); 216 + const names = JSON.parse(r.stdout).map((s: any) => s.name); 217 + expect(names).toEqual([recent]); 218 + }, 10000); 219 + 220 + it("rejects malformed duration", () => { 221 + const dir = makeSessionDir(); 222 + const r = runCli(dir, "list", "--older-than", "1week"); 223 + expect(r.status).not.toBe(0); 224 + expect(r.stderr).toContain("duration"); 225 + }, 10000); 226 + 227 + it("composes with --filter-tag", () => { 228 + const dir = makeSessionDir(); 229 + const oldMatch = uniqueName(); 230 + const oldSkip = uniqueName(); 231 + const TWO_HOURS_AGO = new Date(Date.now() - 2 * 3600_000).toISOString(); 232 + writeFakeMetadata(dir, oldMatch, { createdAt: TWO_HOURS_AGO, tags: { env: "prod" } }); 233 + writeFakeMetadata(dir, oldSkip, { createdAt: TWO_HOURS_AGO, tags: { env: "dev" } }); 234 + 235 + const r = runCli( 236 + dir, "list", "--json", "--older-than", "1h", "--filter-tag", "env=prod", 237 + ); 238 + expect(JSON.parse(r.stdout).map((s: any) => s.name)).toEqual([oldMatch]); 239 + }, 10000); 240 + }); 241 + 242 + describe("pty list --summary", () => { 243 + it("emits counts + oldest/newest in text mode", () => { 244 + const dir = makeSessionDir(); 245 + const oldName = uniqueName(); 246 + const recentName = uniqueName(); 247 + writeFakeMetadata(dir, oldName, { 248 + createdAt: new Date(Date.now() - 2 * 3600_000).toISOString(), 249 + exitedAt: new Date(Date.now() - 60_000).toISOString(), 250 + exitCode: 0, 251 + }); 252 + writeFakeMetadata(dir, recentName, { createdAt: new Date().toISOString() }); 253 + 254 + const r = runCli(dir, "list", "--summary"); 255 + expect(r.status).toBe(0); 256 + expect(r.stdout).toContain("2 sessions"); 257 + expect(r.stdout).toContain("1 exited"); 258 + expect(r.stdout).toContain("1 vanished"); 259 + expect(r.stdout).toContain(`oldest: ${oldName}`); 260 + expect(r.stdout).toContain(`newest: ${recentName}`); 261 + }, 10000); 262 + 263 + it("emits structured JSON when --summary --json", () => { 264 + const dir = makeSessionDir(); 265 + const only = uniqueName(); 266 + writeFakeMetadata(dir, only, { 267 + createdAt: new Date(Date.now() - 5 * 60_000).toISOString(), 268 + }); 269 + 270 + const r = runCli(dir, "list", "--json", "--summary"); 271 + const payload = JSON.parse(r.stdout); 272 + expect(payload.total).toBe(1); 273 + expect(payload.byStatus.vanished).toBe(1); 274 + expect(payload.byStatus.exited).toBe(0); 275 + expect(payload.byStatus.running).toBe(0); 276 + expect(payload.oldest.name).toBe(only); 277 + expect(payload.oldest.status).toBe("vanished"); 278 + expect(payload.oldest.ageSeconds).toBeGreaterThanOrEqual(295); 279 + expect(payload.newest.name).toBe(only); 280 + }, 10000); 281 + 282 + it("summary respects --status filter", () => { 283 + const dir = makeSessionDir(); 284 + const exited = uniqueName(); 285 + const lost = uniqueName(); 286 + writeFakeMetadata(dir, exited, { 287 + createdAt: new Date(Date.now() - 60_000).toISOString(), 288 + exitedAt: new Date().toISOString(), 289 + exitCode: 0, 290 + }); 291 + writeFakeMetadata(dir, lost, { createdAt: new Date().toISOString() }); 292 + 293 + const r = runCli(dir, "list", "--json", "--summary", "--status", "vanished"); 294 + const payload = JSON.parse(r.stdout); 295 + expect(payload.total).toBe(1); 296 + expect(payload.byStatus.vanished).toBe(1); 297 + expect(payload.byStatus.exited).toBe(0); 298 + expect(payload.oldest.name).toBe(lost); 299 + }, 10000); 300 + 301 + it("summary reports 'No matching sessions.' when the filter set is empty", () => { 302 + const dir = makeSessionDir(); 303 + writeFakeMetadata(dir, uniqueName(), { createdAt: new Date().toISOString() }); 304 + 305 + const r = runCli(dir, "list", "--summary", "--status", "running"); 306 + expect(r.stdout).toContain("No matching sessions."); 307 + }, 10000); 308 + });