This repository has no description
0

Configure Feed

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

Fix process leak, FDA wrapper, events --wait, and displayCommand

spawnDaemon now kills orphaned daemons on timeout. launchd install
compiles a C wrapper binary for FDA — grant it Full Disk Access
instead of node. Install flow validates FDA via one-shot launchd
job with interactive prompt. Fix events --wait timeout not being
cancelled on match. Fix displayCommand showing duplicated command
for toml sessions. PATH baked into wrapper at compile time.

Nathan Herald (Apr 14, 2026, 3:42 PM +0200) f8df10dc 6534fa02

+714 -35
+14
CHANGELOG.md
··· 2 2 3 3 ## 0.7.1 4 4 5 + ### launchd 6 + - `pty supervisor launchd install` now compiles a small C wrapper binary (`pty-supervisor`) that validates Full Disk Access before exec'ing node — grant FDA to this binary, not to node itself 7 + - Install flow checks FDA via a one-shot launchd job (no false positives from terminal's FDA) 8 + - Interactive prompt guides user through granting FDA, opens Finder to the binary, verifies after confirmation 9 + - Wrapper bakes in PATH at compile time so child processes can find deno, claude, etc. 10 + - `--path` flag to override the baked-in PATH: `pty supervisor launchd install --path "$PATH"` 11 + - Wrapper runs `--check` for diagnostics: validates node, bundle, and FDA 12 + 5 13 ### Fixes 14 + - Fix `spawnDaemon` leaking orphaned daemon processes on failure — child is now killed if `waitForSocket` times out 15 + - Fix `events --wait` timeout not being cancelled when event is found (caused exit code 1 even on match) 16 + - Fix `displayCommand` duplication in `pty list` for toml-spawned sessions 17 + - `displayCommand` now includes full command + args for `pty run` sessions 6 18 - `pty peek` now works on exited sessions by reading saved output from metadata 7 19 - `pty peek --wait` handles exited sessions: checks saved output, shows last lines and exit code if pattern not found 8 20 - `--wait` accepts multiple patterns (`--wait "passed" --wait "failed"`) — matches on any ··· 10 22 - Exit metadata saved twice: immediately in `onExit` (for status display) and again in `close()` (for complete output after all PTY data has flushed) 11 23 - Fix TUI race where session showed as "running" after exit (delay list refresh 200ms to let metadata flush) 12 24 - Fix SKILL.md examples to use multiple `--wait` flags instead of regex syntax 25 + - Supervisor logs every skip reason in `doRestart` for debugging 26 + - Supervisor state directory moved to `~/.local/state/pty/supervisor/` (no longer pollutes session dir) 13 27 14 28 ## 0.7.0 15 29
+129
scripts/supervisor-wrapper.c
··· 1 + /* 2 + * FDA wrapper for the pty supervisor. 3 + * Compiled during `pty supervisor launchd install` with paths and PATH 4 + * baked in via -D flags. Grant this binary Full Disk Access so the 5 + * supervisor (and sessions it spawns) can access external/removable volumes. 6 + * 7 + * Usage: 8 + * pty-supervisor Run the supervisor (exec node with bundle) 9 + * pty-supervisor --check Validate FDA, node binary, bundle file, and PATH 10 + * 11 + * Compile: 12 + * cc -O2 -o pty-supervisor \ 13 + * -DNODE_PATH='"/path/to/node"' \ 14 + * -DBUNDLE_PATH='"/path/to/supervisor.bundle.js"' \ 15 + * -DUSER_PATH='"..."' \ 16 + * scripts/supervisor-wrapper.c 17 + */ 18 + #include <unistd.h> 19 + #include <stdio.h> 20 + #include <stdlib.h> 21 + #include <string.h> 22 + 23 + #ifndef NODE_PATH 24 + #error "NODE_PATH must be defined at compile time" 25 + #endif 26 + 27 + #ifndef BUNDLE_PATH 28 + #error "BUNDLE_PATH must be defined at compile time" 29 + #endif 30 + 31 + #ifndef USER_PATH 32 + #error "USER_PATH must be defined at compile time" 33 + #endif 34 + 35 + /* Test FDA by reading a TCC-protected file */ 36 + static int check_fda(void) { 37 + const char *home = getenv("HOME"); 38 + if (!home) { 39 + fprintf(stderr, " ✗ HOME not set\n"); 40 + return 0; 41 + } 42 + char path[1024]; 43 + snprintf(path, sizeof(path), "%s/Library/Safari/History.db", home); 44 + if (access(path, R_OK) == 0) { 45 + return 1; 46 + } 47 + return 0; 48 + } 49 + 50 + static int check_file(const char *label, const char *path) { 51 + if (access(path, R_OK) == 0) { 52 + printf(" ✓ %s: %s\n", label, path); 53 + return 1; 54 + } 55 + fprintf(stderr, " ✗ %s not found: %s\n", label, path); 56 + return 0; 57 + } 58 + 59 + static int check_executable(const char *label, const char *path) { 60 + if (access(path, X_OK) == 0) { 61 + printf(" ✓ %s: %s\n", label, path); 62 + return 1; 63 + } 64 + fprintf(stderr, " ✗ %s not executable: %s\n", label, path); 65 + return 0; 66 + } 67 + 68 + int main(int argc, char *argv[]) { 69 + /* Set PATH before anything else so child processes can find commands */ 70 + setenv("PATH", USER_PATH, 1); 71 + 72 + int check_mode = 0; 73 + for (int i = 1; i < argc; i++) { 74 + if (strcmp(argv[i], "--check") == 0) { 75 + check_mode = 1; 76 + } 77 + } 78 + 79 + if (check_mode) { 80 + printf("pty supervisor wrapper check\n\n"); 81 + int ok = 1; 82 + 83 + if (!check_executable("node", NODE_PATH)) ok = 0; 84 + if (!check_file("bundle", BUNDLE_PATH)) ok = 0; 85 + printf(" ✓ PATH: set (%zu chars)\n", strlen(USER_PATH)); 86 + 87 + if (check_fda()) { 88 + printf(" ✓ Full Disk Access: granted\n"); 89 + } else { 90 + fprintf(stderr, " ✗ Full Disk Access: not granted\n"); 91 + fprintf(stderr, "\n"); 92 + fprintf(stderr, "Grant Full Disk Access to this binary:\n"); 93 + fprintf(stderr, " System Settings > Privacy & Security > Full Disk Access\n"); 94 + fprintf(stderr, " Add: %s\n", argv[0]); 95 + ok = 0; 96 + } 97 + 98 + printf("\n"); 99 + if (ok) { 100 + printf("All checks passed.\n"); 101 + return 0; 102 + } else { 103 + fprintf(stderr, "Some checks failed.\n"); 104 + return 1; 105 + } 106 + } 107 + 108 + /* Normal mode: validate before exec */ 109 + if (access(NODE_PATH, X_OK) != 0) { 110 + fprintf(stderr, "[pty-supervisor] node not found: %s\n", NODE_PATH); 111 + return 1; 112 + } 113 + if (access(BUNDLE_PATH, R_OK) != 0) { 114 + fprintf(stderr, "[pty-supervisor] bundle not found: %s\n", BUNDLE_PATH); 115 + return 1; 116 + } 117 + if (!check_fda()) { 118 + fprintf(stderr, "[pty-supervisor] Full Disk Access not granted.\n"); 119 + fprintf(stderr, "[pty-supervisor] Grant FDA to: %s\n", argv[0]); 120 + fprintf(stderr, "[pty-supervisor] System Settings > Privacy & Security > Full Disk Access\n"); 121 + return 1; 122 + } 123 + 124 + char *exec_argv[] = { NODE_PATH, BUNDLE_PATH, NULL }; 125 + execv(NODE_PATH, exec_argv); 126 + /* execv only returns on error */ 127 + perror("[pty-supervisor] execv"); 128 + return 1; 129 + }
+154 -20
src/cli.ts
··· 186 186 process.exit(1); 187 187 } 188 188 189 - const displayCmd = cmd; 189 + const autoNameCmd = cmd; 190 + const displayCmd = [cmd, ...cmdArgs].join(" "); 190 191 try { 191 192 cmd = resolveCommand(cmd); 192 193 } catch (e: any) { ··· 210 211 if (!name) { 211 212 const sessions = await listSessions(); 212 213 const existing = new Set(sessions.map(s => s.name)); 213 - let candidate = autoName(displayCmd, cmdArgs); 214 + let candidate = autoName(autoNameCmd, cmdArgs); 214 215 // Sanitize: validateName allows [a-zA-Z0-9._-] 215 216 candidate = candidate.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); 216 217 // Dedup ··· 580 581 pty supervisor status Show supervised sessions 581 582 pty supervisor forget <name> Stop supervising a session 582 583 pty supervisor reset <name> Reset a failed session for retry 583 - pty supervisor launchd install Register with macOS launchd 584 + pty supervisor launchd install [--path PATH] Register with macOS launchd 584 585 pty supervisor launchd uninstall Remove from launchd`); 585 586 break; 586 587 } ··· 615 616 case "launchd": { 616 617 const launchdCmd = args[2]; 617 618 if (launchdCmd === "install") { 618 - cmdSupervisorLaunchdInstall(); 619 + // Parse --path flag 620 + let userPath: string | undefined; 621 + for (let li = 3; li < args.length; li++) { 622 + if (args[li] === "--path" && li + 1 < args.length) { 623 + userPath = args[li + 1]; 624 + break; 625 + } 626 + } 627 + await cmdSupervisorLaunchdInstall(userPath); 619 628 } else if (launchdCmd === "uninstall") { 620 629 cmdSupervisorLaunchdUninstall(); 621 630 } else { ··· 896 905 status: s.status, 897 906 pid: s.pid, 898 907 command: s.metadata 899 - ? [s.metadata.displayCommand, ...(s.metadata.args ?? [])].join(" ") 908 + ? s.metadata.displayCommand 900 909 : null, 901 910 cwd: s.metadata?.cwd ?? null, 902 911 createdAt: s.metadata?.createdAt ?? null, ··· 920 929 console.log("Active sessions:"); 921 930 for (const session of running) { 922 931 const cmd = session.metadata 923 - ? [session.metadata.displayCommand, ...(session.metadata.args ?? [])].join(" ") 932 + ? session.metadata.displayCommand 924 933 : "unknown"; 925 934 const cwd = session.metadata?.cwd 926 935 ? shortPath(session.metadata.cwd) ··· 942 951 const ago = meta?.exitedAt ? timeAgo(new Date(meta.exitedAt)) : "unknown"; 943 952 const cwd = meta?.cwd ? shortPath(meta.cwd) : ""; 944 953 const cmd = meta 945 - ? [meta.displayCommand, ...(meta.args ?? [])].join(" ") 954 + ? meta.displayCommand 946 955 : ""; 947 956 const tagStr = showTags && meta?.tags 948 957 ? " " + Object.entries(meta.tags).map(([k, v]) => `#${k}=${v}`).join(" ") ··· 1057 1066 1058 1067 function printStats(stats: StatsResult, meta: SessionInfo["metadata"]): void { 1059 1068 const cmd = meta 1060 - ? [meta.displayCommand, ...(meta.args ?? [])].join(" ") 1069 + ? meta.displayCommand 1061 1070 : "unknown"; 1062 1071 const cwd = meta?.cwd ? shortPath(meta.cwd) : "unknown"; 1063 1072 ··· 1336 1345 console.log(`Reset "${name}". The supervisor will try restarting it.`); 1337 1346 } 1338 1347 1339 - function cmdSupervisorLaunchdInstall(): void { 1348 + async function cmdSupervisorLaunchdInstall(userPath?: string): Promise<void> { 1340 1349 const launchdDir = path.join(os.homedir(), ".local", "pty", "launchd"); 1350 + const wrapperPath = path.join(launchdDir, "pty-supervisor"); 1341 1351 const bundlePath = path.join(launchdDir, "supervisor.bundle.js"); 1342 1352 const logPath = path.join(os.homedir(), ".local", "state", "pty", "supervisor.log"); 1343 1353 const plistDir = path.join(os.homedir(), "Library", "LaunchAgents"); ··· 1357 1367 spawnSync("launchctl", ["unload", plistPath], { encoding: "utf-8" }); 1358 1368 } 1359 1369 1360 - // Bundle the supervisor into a single portable JS file 1361 - const distDir = path.join( 1370 + const srcRoot = path.join( 1362 1371 import.meta.dirname ?? path.dirname(new URL(import.meta.url).pathname), 1363 - "..", "dist" 1372 + ".." 1364 1373 ); 1374 + const distDir = path.join(srcRoot, "dist"); 1375 + const nodeBin = process.execPath; 1376 + const wrapperSrc = path.join(srcRoot, "scripts", "supervisor-wrapper.c"); 1377 + 1378 + // Clean and create launchd directory 1379 + fs.rmSync(launchdDir, { recursive: true, force: true }); 1380 + fs.mkdirSync(launchdDir, { recursive: true }); 1381 + 1382 + // Bundle the supervisor 1365 1383 const entryPoint = path.join(distDir, "supervisor-entry.js"); 1384 + const serverModule = path.join(distDir, "server.js"); 1366 1385 1367 1386 console.log("Bundling supervisor..."); 1368 - const serverModule = path.join(distDir, "server.js"); 1369 1387 const esbuildResult = spawnSync("npx", ["esbuild", entryPoint, "--bundle", "--platform=node", "--format=esm", `--outfile=${bundlePath}`, `--define:SERVER_MODULE_PATH="${serverModule.replace(/\\/g, "\\\\")}"`], { 1370 1388 encoding: "utf-8", 1371 1389 cwd: distDir, ··· 1375 1393 process.exit(1); 1376 1394 } 1377 1395 1378 - // Use absolute path to current node binary — no PATH dependency 1379 - const nodeBin = process.execPath; 1396 + // Compile the FDA wrapper binary with PATH baked in 1397 + const envPath = userPath ?? process.env.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin"; 1398 + console.log("Compiling FDA wrapper..."); 1399 + const ccResult = spawnSync("cc", [ 1400 + "-O2", "-o", wrapperPath, 1401 + `-DNODE_PATH="${nodeBin}"`, 1402 + `-DBUNDLE_PATH="${bundlePath}"`, 1403 + `-DUSER_PATH="${envPath}"`, 1404 + wrapperSrc, 1405 + ], { encoding: "utf-8" }); 1406 + if (ccResult.status !== 0) { 1407 + console.error(`Failed to compile wrapper: ${ccResult.stderr}`); 1408 + process.exit(1); 1409 + } 1410 + 1411 + // Run --check via a one-shot launchd job to test under launchd's actual 1412 + // permission scope (not the terminal's). This is the only reliable way to 1413 + // know if the wrapper binary has FDA. 1414 + function checkFDAViaLaunchd(): boolean { 1415 + const checkLabel = "com.myobie.pty.fda-check"; 1416 + const checkOutput = path.join(launchdDir, "fda-check.log"); 1417 + const checkPlist = path.join(launchdDir, "fda-check.plist"); 1380 1418 1419 + try { fs.unlinkSync(checkOutput); } catch {} 1420 + 1421 + fs.writeFileSync(checkPlist, `<?xml version="1.0" encoding="UTF-8"?> 1422 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 1423 + <plist version="1.0"> 1424 + <dict> 1425 + <key>Label</key> 1426 + <string>${checkLabel}</string> 1427 + <key>ProgramArguments</key> 1428 + <array> 1429 + <string>${wrapperPath}</string> 1430 + <string>--check</string> 1431 + </array> 1432 + <key>StandardOutPath</key> 1433 + <string>${checkOutput}</string> 1434 + <key>StandardErrorPath</key> 1435 + <string>${checkOutput}</string> 1436 + <key>RunAtLoad</key> 1437 + <true/> 1438 + </dict> 1439 + </plist> 1440 + `); 1441 + 1442 + // Unload any previous check job 1443 + spawnSync("launchctl", ["unload", checkPlist], { encoding: "utf-8" }); 1444 + 1445 + // Load — runs immediately due to RunAtLoad 1446 + spawnSync("launchctl", ["load", checkPlist], { encoding: "utf-8" }); 1447 + 1448 + // Wait for the job to finish (it's a one-shot, exits quickly) 1449 + const start = Date.now(); 1450 + while (Date.now() - start < 5000) { 1451 + const list = spawnSync("launchctl", ["list"], { encoding: "utf-8" }); 1452 + const line = list.stdout.split("\n").find((l: string) => l.includes(checkLabel)); 1453 + // Format: "PID\tExitCode\tLabel" — if PID is "-", the job has exited 1454 + if (line && line.startsWith("-")) break; 1455 + spawnSync("sleep", ["0.2"]); 1456 + } 1457 + 1458 + // Unload the check job 1459 + spawnSync("launchctl", ["unload", checkPlist], { encoding: "utf-8" }); 1460 + try { fs.unlinkSync(checkPlist); } catch {} 1461 + 1462 + // Read the output 1463 + try { 1464 + const output = fs.readFileSync(checkOutput, "utf-8"); 1465 + try { fs.unlinkSync(checkOutput); } catch {} 1466 + return output.includes("All checks passed"); 1467 + } catch { 1468 + return false; 1469 + } 1470 + } 1471 + 1472 + console.log("Checking Full Disk Access via launchd..."); 1473 + let hasFDA = checkFDAViaLaunchd(); 1474 + 1475 + if (!hasFDA) { 1476 + console.log(""); 1477 + console.log("The supervisor wrapper needs Full Disk Access to manage"); 1478 + console.log("sessions on external/removable volumes."); 1479 + console.log(""); 1480 + console.log("1. Open System Settings > Privacy & Security > Full Disk Access"); 1481 + console.log(`2. Click + and add: ${wrapperPath}`); 1482 + console.log(""); 1483 + 1484 + // Open the folder in Finder so they can find the binary 1485 + spawnSync("open", [launchdDir]); 1486 + 1487 + const rl = await import("node:readline/promises"); 1488 + const prompt = rl.createInterface({ input: process.stdin, output: process.stdout }); 1489 + const answer = await prompt.question("Have you granted Full Disk Access? [y/N] "); 1490 + prompt.close(); 1491 + 1492 + if (answer.trim().toLowerCase() !== "y") { 1493 + console.error("Aborted. Full Disk Access is required for launchd."); 1494 + process.exit(1); 1495 + } 1496 + 1497 + // Re-check via launchd 1498 + console.log("Verifying..."); 1499 + hasFDA = checkFDAViaLaunchd(); 1500 + if (!hasFDA) { 1501 + console.error(""); 1502 + console.error("Full Disk Access check failed under launchd."); 1503 + console.error("The plist was NOT loaded. Grant FDA and try again:"); 1504 + console.error(` ${wrapperPath}`); 1505 + process.exit(1); 1506 + } 1507 + console.log("Full Disk Access verified."); 1508 + } else { 1509 + console.log("Full Disk Access: granted."); 1510 + } 1511 + 1512 + // Write and load the plist — FDA is confirmed 1381 1513 const plist = `<?xml version="1.0" encoding="UTF-8"?> 1382 1514 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 1383 1515 <plist version="1.0"> ··· 1386 1518 <string>com.myobie.pty.supervisor</string> 1387 1519 <key>ProgramArguments</key> 1388 1520 <array> 1389 - <string>${nodeBin}</string> 1390 - <string>${bundlePath}</string> 1521 + <string>${wrapperPath}</string> 1391 1522 </array> 1392 1523 <key>StandardOutPath</key> 1393 1524 <string>${logPath}</string> ··· 1403 1534 </plist> 1404 1535 `; 1405 1536 1406 - fs.mkdirSync(launchdDir, { recursive: true }); 1407 1537 fs.mkdirSync(plistDir, { recursive: true }); 1408 1538 fs.writeFileSync(plistPath, plist); 1409 1539 ··· 1413 1543 process.exit(1); 1414 1544 } 1415 1545 1546 + console.log(""); 1547 + console.log(`Wrapper: ${wrapperPath}`); 1416 1548 console.log(`Bundle: ${bundlePath}`); 1417 1549 console.log(`Plist: ${plistPath}`); 1418 1550 console.log(`Log: ${logPath}`); 1419 - console.log(`Node: ${nodeBin}`); 1420 1551 console.log("Supervisor will start on login and restart if it exits."); 1421 1552 } 1422 1553 ··· 1690 1821 const start = Date.now(); 1691 1822 1692 1823 return new Promise<void>((resolve) => { 1824 + let timer: ReturnType<typeof setTimeout> | null = null; 1825 + 1693 1826 const follower = new EventFollower({ 1694 1827 names: [name!], 1695 1828 onEvent: (event) => { 1696 1829 if (event.type === opts.waitEventType) { 1830 + if (timer) clearTimeout(timer); 1697 1831 console.log(opts.json ? JSON.stringify(event) : formatEvent(event)); 1698 1832 follower.stop(); 1699 1833 resolve(); ··· 1704 1838 follower.start(); 1705 1839 1706 1840 if (timeoutMs > 0) { 1707 - setTimeout(() => { 1841 + timer = setTimeout(() => { 1708 1842 follower.stop(); 1709 1843 console.error(`Timed out after ${opts.timeout}s waiting for "${opts.waitEventType}" event.`); 1710 1844 process.exit(1);
+14 -6
src/spawn.ts
··· 64 64 (child.stderr as any)?.unref?.(); 65 65 child.unref(); 66 66 67 - await waitForSocket(options.name, 3000, () => { 68 - if (earlyExit) { 69 - const details = stderrOutput.trim(); 70 - const msg = `Daemon process exited immediately (code ${earlyExitCode ?? "unknown"}).`; 71 - throw new Error(details ? `${msg}\n${details}` : `${msg} Is the command valid?`); 67 + try { 68 + await waitForSocket(options.name, 3000, () => { 69 + if (earlyExit) { 70 + const details = stderrOutput.trim(); 71 + const msg = `Daemon process exited immediately (code ${earlyExitCode ?? "unknown"}).`; 72 + throw new Error(details ? `${msg}\n${details}` : `${msg} Is the command valid?`); 73 + } 74 + }); 75 + } catch (err) { 76 + // Kill the orphaned daemon process so it doesn't leak 77 + if (!earlyExit && child.pid) { 78 + try { process.kill(child.pid, "SIGTERM"); } catch {} 72 79 } 73 - }); 80 + throw err; 81 + } 74 82 } 75 83 76 84 export function waitForSocket(
+20 -7
src/supervisor.ts
··· 271 271 272 272 tracked.pendingTimer = setTimeout(() => { 273 273 tracked.pendingTimer = null; 274 + console.log(`[supervisor] attempting restart for ${name}...`); 274 275 this.doRestart(name).catch((err) => { 275 - console.error(`[supervisor] restart failed for ${name}: ${err.message}`); 276 + console.log(`[supervisor] restart failed for ${name}: ${err.message}`); 276 277 tracked.restartCount++; 277 278 tracked.nextBackoffMs = Math.min(tracked.nextBackoffMs * BACKOFF_MULTIPLIER, MAX_BACKOFF_MS); 278 279 this.persistState(); ··· 287 288 } 288 289 289 290 private async doRestart(name: string): Promise<void> { 290 - if (this.stopping) return; 291 + if (this.stopping) { 292 + console.log(`[supervisor] skipping restart for ${name} (stopping)`); 293 + return; 294 + } 291 295 292 - // Re-read metadata to verify session is still exited and supervised 293 296 const metadata = readMetadata(name); 294 - if (!metadata) return; 295 - if (metadata.tags?.strategy !== "permanent") return; 297 + if (!metadata) { 298 + console.log(`[supervisor] skipping restart for ${name} (no metadata)`); 299 + return; 300 + } 301 + if (metadata.tags?.strategy !== "permanent") { 302 + console.log(`[supervisor] skipping restart for ${name} (strategy removed)`); 303 + return; 304 + } 296 305 297 306 // Check if actually still dead (exitedAt may be missing if killed externally) 298 307 if (!metadata.exitedAt) { ··· 300 309 try { 301 310 const pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10); 302 311 process.kill(pid, 0); 303 - return; // still alive, skip restart 312 + console.log(`[supervisor] skipping restart for ${name} (pid ${pid} still alive)`); 313 + return; 304 314 } catch { 305 315 // dead — proceed with restart 306 316 } 307 317 } 308 318 309 319 const tracked = this.sessions.get(name); 310 - if (!tracked) return; 320 + if (!tracked) { 321 + console.log(`[supervisor] skipping restart for ${name} (not tracked)`); 322 + return; 323 + } 311 324 312 325 // If session was started from a pty.toml, re-read it for the latest config 313 326 let command = metadata.command;
+2 -2
src/tui/interactive.ts
··· 104 104 continue; 105 105 } 106 106 const cmd = s.metadata 107 - ? [s.metadata.displayCommand, ...(s.metadata.args ?? [])].join(" ") 107 + ? s.metadata.displayCommand ?? "" 108 108 : ""; 109 109 const cwd = s.metadata?.cwd ?? ""; 110 110 // Fuzzy match against name (weighted highest), cwd, and command ··· 141 141 const s = item.session!; 142 142 const icon = s.status === "running" ? "\u25cf" : "\u25cb"; 143 143 const cmd = s.metadata 144 - ? [s.metadata.displayCommand, ...(s.metadata.args ?? [])].join(" ") 144 + ? s.metadata.displayCommand ?? "" 145 145 : ""; 146 146 const cwdStr = s.metadata?.cwd ? shortPath(s.metadata.cwd) : ""; 147 147 const exitStr = s.metadata?.exitedAt ? `(exited ${timeAgo(new Date(s.metadata.exitedAt))})` : "";
+74
tests/peek-wait.test.ts
··· 144 144 expect(result.stdout).toContain("ALREADY"); 145 145 expect(elapsed).toBeLessThan(2000); 146 146 }, 15000); 147 + 148 + it("multiple --wait patterns match on any", async () => { 149 + const dir = makeSessionDir(); 150 + const name = uniqueName(); 151 + await startDaemon(dir, name, "sh", ["-c", "echo SECOND; exec cat"]); 152 + await new Promise((r) => setTimeout(r, 300)); 153 + 154 + const result = runCli(dir, "peek", "--wait", "FIRST", "--wait", "SECOND", "-t", "5", "--plain", name); 155 + 156 + expect(result.status).toBe(0); 157 + expect(result.stdout).toContain("SECOND"); 158 + }, 15000); 159 + 160 + it("peek --wait reads saved output from exited session", async () => { 161 + const dir = makeSessionDir(); 162 + const name = uniqueName(); 163 + await startDaemon(dir, name, "sh", ["-c", "echo TEST_PASSED; exit 0"]); 164 + await new Promise((r) => setTimeout(r, 1500)); // wait for exit + metadata 165 + 166 + const result = runCli(dir, "peek", "--wait", "TEST_PASSED", "-t", "5", "--plain", name); 167 + 168 + expect(result.status).toBe(0); 169 + expect(result.stdout).toContain("TEST_PASSED"); 170 + }, 15000); 171 + 172 + it("peek --wait on exited session shows error when pattern not found", async () => { 173 + const dir = makeSessionDir(); 174 + const name = uniqueName(); 175 + await startDaemon(dir, name, "sh", ["-c", "echo nope; exit 0"]); 176 + await new Promise((r) => setTimeout(r, 1500)); 177 + 178 + const result = runCli(dir, "peek", "--wait", "MISSING", "-t", "5", "--plain", name); 179 + 180 + expect(result.status).toBe(1); 181 + expect(result.stderr).toContain("exited"); 182 + expect(result.stderr).toContain("MISSING"); 183 + }, 15000); 184 + 185 + it("peek --plain on exited session shows saved output", async () => { 186 + const dir = makeSessionDir(); 187 + const name = uniqueName(); 188 + await startDaemon(dir, name, "sh", ["-c", "echo SAVED_OUTPUT; exit 0"]); 189 + await new Promise((r) => setTimeout(r, 1500)); 190 + 191 + const result = runCli(dir, "peek", "--plain", name); 192 + 193 + expect(result.status).toBe(0); 194 + expect(result.stdout).toContain("SAVED_OUTPUT"); 195 + }, 15000); 196 + }); 197 + 198 + describe("pty events --wait", () => { 199 + it("waits for a specific event type", async () => { 200 + const dir = makeSessionDir(); 201 + const name = uniqueName(); 202 + // bell event: \x07 — delay enough for the events follower to start watching 203 + await startDaemon(dir, name, "sh", ["-c", "sleep 2; printf '\\007'; exec cat"]); 204 + 205 + const result = runCli(dir, "events", "--wait", "bell", "-t", "10", name); 206 + 207 + expect(result.status).toBe(0); 208 + expect(result.stdout).toContain("bell"); 209 + }, 20000); 210 + 211 + it("times out when event never occurs", async () => { 212 + const dir = makeSessionDir(); 213 + const name = uniqueName(); 214 + await startDaemon(dir, name, "cat"); 215 + 216 + const result = runCli(dir, "events", "--wait", "bell", "-t", "1", name); 217 + 218 + expect(result.status).toBe(1); 219 + expect(result.stderr).toContain("Timed out"); 220 + }, 15000); 147 221 });
+307
tests/supervisor-hardening.test.ts
··· 1 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import * as path from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + import { spawn, spawnSync } from "node:child_process"; 7 + 8 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 + const nodeBin = process.execPath; 10 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 11 + const serverModule = path.join(__dirname, "..", "dist", "server.js"); 12 + 13 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-sup-hard-")); 14 + afterAll(() => { 15 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 16 + }); 17 + 18 + let bgPids: number[] = []; 19 + let sessionDirs: string[] = []; 20 + 21 + function makeSessionDir(): string { 22 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 23 + sessionDirs.push(dir); 24 + return dir; 25 + } 26 + 27 + let nameCounter = 0; 28 + function uniqueName(): string { 29 + return `sh${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; 30 + } 31 + 32 + async function startDaemon( 33 + sessionDir: string, 34 + name: string, 35 + command: string, 36 + args: string[] = [], 37 + tags?: Record<string, string>, 38 + ): Promise<number> { 39 + const config = JSON.stringify({ 40 + name, command, args, displayCommand: command, 41 + cwd: os.tmpdir(), rows: 24, cols: 80, tags, 42 + }); 43 + const child = spawn(nodeBin, [serverModule], { 44 + detached: true, 45 + stdio: ["ignore", "ignore", "pipe"], 46 + env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, 47 + }); 48 + let stderr = ""; 49 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 50 + let exitCode: number | null = null; 51 + child.on("exit", (code) => { exitCode = code; }); 52 + (child.stderr as any)?.unref?.(); 53 + child.unref(); 54 + 55 + const socketPath = path.join(sessionDir, `${name}.sock`); 56 + const start = Date.now(); 57 + while (Date.now() - start < 5000) { 58 + if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); 59 + try { 60 + fs.statSync(socketPath); 61 + await new Promise((r) => setTimeout(r, 100)); 62 + bgPids.push(child.pid!); 63 + return child.pid!; 64 + } catch {} 65 + await new Promise((r) => setTimeout(r, 50)); 66 + } 67 + throw new Error("Timeout waiting for daemon"); 68 + } 69 + 70 + function runCli(sessionDir: string, ...args: string[]) { 71 + return spawnSync(nodeBin, [cliPath, ...args], { 72 + env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 73 + encoding: "utf-8", 74 + timeout: 15000, 75 + }); 76 + } 77 + 78 + function readMeta(sessionDir: string, name: string): any { 79 + try { 80 + return JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf-8")); 81 + } catch { return null; } 82 + } 83 + 84 + afterEach(() => { 85 + for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 86 + bgPids = []; 87 + for (const dir of sessionDirs) { 88 + try { 89 + for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 90 + } catch {} 91 + } 92 + sessionDirs = []; 93 + }); 94 + 95 + describe("displayCommand formatting", () => { 96 + it("pty run shows command with args in list", () => { 97 + const dir = makeSessionDir(); 98 + const name = uniqueName(); 99 + 100 + runCli(dir, "run", "-d", "--name", name, "--", "echo", "hello", "world"); 101 + // Wait for session to start 102 + const start = Date.now(); 103 + while (Date.now() - start < 3000) { 104 + const meta = readMeta(dir, name); 105 + if (meta) break; 106 + } 107 + 108 + const meta = readMeta(dir, name); 109 + expect(meta).not.toBeNull(); 110 + expect(meta.displayCommand).toBe("echo hello world"); 111 + }, 15000); 112 + 113 + it("pty up shows toml command without duplication in list", () => { 114 + const projDir = fs.mkdtempSync(path.join(testRoot, "proj-")); 115 + const dir = makeSessionDir(); 116 + fs.writeFileSync(path.join(projDir, "pty.toml"), ` 117 + [sessions.serve] 118 + command = "echo server running" 119 + `); 120 + 121 + runCli(dir, "up", projDir); 122 + 123 + const meta = readMeta(dir, "serve"); 124 + expect(meta).not.toBeNull(); 125 + expect(meta.displayCommand).toBe("echo server running"); 126 + // command should be /bin/sh, args should be ["-c", "echo server running"] 127 + expect(meta.command).toBe("/bin/sh"); 128 + 129 + // List output should show the command once, not duplicated 130 + const list = runCli(dir, "list"); 131 + const lines = list.stdout.split("\n").filter((l: string) => l.includes("serve")); 132 + expect(lines.length).toBeGreaterThan(0); 133 + // Count occurrences of "echo server running" — should be 1 134 + const matches = lines[0].match(/echo server running/g); 135 + expect(matches).toHaveLength(1); 136 + }, 15000); 137 + }); 138 + 139 + describe("pty kill on supervised sessions", () => { 140 + it("removes strategy tag when killing a supervised session", async () => { 141 + const dir = makeSessionDir(); 142 + const name = uniqueName(); 143 + await startDaemon(dir, name, "cat", [], { strategy: "permanent" }); 144 + 145 + const result = runCli(dir, "kill", name); 146 + expect(result.status).toBe(0); 147 + 148 + // Strategy tag should be removed 149 + const meta = readMeta(dir, name); 150 + // Meta might be null if cleanup happened, or present without strategy 151 + if (meta?.tags) { 152 + expect(meta.tags.strategy).toBeUndefined(); 153 + } 154 + }, 15000); 155 + 156 + it("warns about toml-managed sessions when killing", async () => { 157 + const dir = makeSessionDir(); 158 + const name = uniqueName(); 159 + await startDaemon(dir, name, "cat", [], { 160 + strategy: "permanent", 161 + ptyfile: "/some/path/pty.toml", 162 + }); 163 + 164 + const result = runCli(dir, "kill", name); 165 + expect(result.stderr).toContain("pty.toml"); 166 + expect(result.stderr).toContain("pty up"); 167 + }, 15000); 168 + }); 169 + 170 + describe("pty supervisor reset", () => { 171 + it("clears failed status and restart counter", async () => { 172 + const dir = makeSessionDir(); 173 + const name = uniqueName(); 174 + await startDaemon(dir, name, "true", [], { 175 + strategy: "permanent", 176 + "supervisor.status": "failed", 177 + }); 178 + await new Promise((r) => setTimeout(r, 1000)); // wait for exit 179 + 180 + // Write fake supervisor state 181 + const supDir = path.join(dir, "supervisor"); 182 + fs.mkdirSync(supDir, { recursive: true }); 183 + fs.writeFileSync(path.join(supDir, "state.json"), JSON.stringify({ 184 + sessions: { [name]: { restartCount: 5, restartWindowStart: Date.now(), failed: true, nextBackoffMs: 16000, lastRestartAt: Date.now() } }, 185 + savedAt: new Date().toISOString(), 186 + })); 187 + 188 + const result = runCli(dir, "supervisor", "reset", name); 189 + expect(result.status).toBe(0); 190 + expect(result.stdout).toContain("Reset"); 191 + 192 + // Failed tag should be removed 193 + const meta = readMeta(dir, name); 194 + expect(meta.tags["supervisor.status"]).toBeUndefined(); 195 + 196 + // State file should have reset counters 197 + const state = JSON.parse(fs.readFileSync(path.join(supDir, "state.json"), "utf-8")); 198 + expect(state.sessions[name].restartCount).toBe(0); 199 + expect(state.sessions[name].failed).toBe(false); 200 + }, 15000); 201 + 202 + it("reports when session is not failed", async () => { 203 + const dir = makeSessionDir(); 204 + const name = uniqueName(); 205 + await startDaemon(dir, name, "cat", [], { strategy: "permanent" }); 206 + 207 + const result = runCli(dir, "supervisor", "reset", name); 208 + expect(result.stdout).toContain("not in failed state"); 209 + }, 15000); 210 + }); 211 + 212 + describe("pty supervisor forget", () => { 213 + it("warns about toml-managed sessions", async () => { 214 + const dir = makeSessionDir(); 215 + const name = uniqueName(); 216 + await startDaemon(dir, name, "cat", [], { 217 + strategy: "permanent", 218 + ptyfile: "/some/path/pty.toml", 219 + }); 220 + 221 + const result = runCli(dir, "supervisor", "forget", name); 222 + expect(result.status).toBe(0); 223 + expect(result.stdout).toContain("Removed supervision"); 224 + expect(result.stderr).toContain("pty.toml"); 225 + expect(result.stderr).toContain("pty up"); 226 + }, 15000); 227 + }); 228 + 229 + describe("pty tag warning", () => { 230 + it("warns when modifying tags on toml-managed sessions", async () => { 231 + const dir = makeSessionDir(); 232 + const name = uniqueName(); 233 + await startDaemon(dir, name, "cat", [], { 234 + ptyfile: "/some/path/pty.toml", 235 + "ptyfile.session": "test", 236 + }); 237 + 238 + const result = runCli(dir, "tag", name, "custom=value"); 239 + expect(result.status).toBe(0); 240 + expect(result.stderr).toContain("pty.toml"); 241 + expect(result.stderr).toContain("pty up"); 242 + }, 15000); 243 + }); 244 + 245 + describe("spawnDaemon process leak", () => { 246 + it("kills orphaned daemon on waitForSocket timeout", () => { 247 + const dir = makeSessionDir(); 248 + 249 + // Count server.js processes before 250 + const before = spawnSync("pgrep", ["-f", "server.js"], { encoding: "utf-8" }); 251 + const beforeCount = before.stdout.trim().split("\n").filter(Boolean).length; 252 + 253 + // Try to spawn with a nonexistent command — daemon will crash 254 + const result = runCli(dir, "run", "-d", "--name", "leak-test", "--", "/nonexistent/command"); 255 + 256 + // Count server.js processes after 257 + const after = spawnSync("pgrep", ["-f", "server.js"], { encoding: "utf-8" }); 258 + const afterCount = after.stdout.trim().split("\n").filter(Boolean).length; 259 + 260 + // Should not have leaked a process 261 + expect(afterCount).toBeLessThanOrEqual(beforeCount); 262 + }, 15000); 263 + }); 264 + 265 + describe("supervisor re-reads pty.toml on restart", () => { 266 + it("uses updated toml command when restarting", async () => { 267 + const projDir = fs.mkdtempSync(path.join(testRoot, "proj-")); 268 + const dir = makeSessionDir(); 269 + 270 + // Create initial toml 271 + fs.writeFileSync(path.join(projDir, "pty.toml"), ` 272 + [sessions.reread] 273 + command = "echo original" 274 + tags = { strategy = "permanent" } 275 + `); 276 + 277 + // Start via pty up 278 + runCli(dir, "up", projDir); 279 + await new Promise((r) => setTimeout(r, 1500)); // wait for exit + metadata 280 + 281 + // Update the toml command 282 + fs.writeFileSync(path.join(projDir, "pty.toml"), ` 283 + [sessions.reread] 284 + command = "echo updated" 285 + tags = { strategy = "permanent" } 286 + `); 287 + 288 + // Start supervisor to restart the session 289 + const sup = spawn(nodeBin, [cliPath, "supervisor", "start"], { 290 + detached: true, 291 + stdio: ["ignore", "ignore", "ignore"], 292 + env: { ...process.env, PTY_SESSION_DIR: dir }, 293 + }); 294 + sup.unref(); 295 + bgPids.push(sup.pid!); 296 + 297 + // Wait for supervisor to restart the session 298 + await new Promise((r) => setTimeout(r, 5000)); 299 + 300 + // Check the restarted session's metadata 301 + const meta = readMeta(dir, "reread"); 302 + if (meta) { 303 + // displayCommand should reflect the updated toml 304 + expect(meta.displayCommand).toBe("echo updated"); 305 + } 306 + }, 20000); 307 + });