This repository has no description
0

Configure Feed

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

Add supervisor, mutable tags, peek --wait/--full, and hardening

Session supervisor watches strategy tags and restarts permanent
sessions with exponential backoff. Runs as a foreground process,
integrates with launchd via esbuild bundling. Mutable tags via
pty tag command. pty kill/down properly handle supervised sessions.

Also: peek --wait blocks until text appears, peek --full shows
scrollback, events --wait blocks for event types, interactive TUI
shows [permanent] markers with color, 10s supervisor scan interval,
supervisor state in its own subdirectory, defensive meta.args
handling, and isolated shell test sessions.

Nathan Herald (Apr 14, 2026, 1:52 PM +0200) 199e6f2f bd591b47

+2015 -49
+13
CHANGELOG.md
··· 6 6 - `pty.toml` supports named sessions with commands and optional tags 7 7 - `pty up` accepts a directory argument (`pty up ./backend`) and session name filtering (`pty up dev serve`) 8 8 - Add `--tags` flag to `pty list` to display tags as `#key=value` hashtags 9 + - Add session supervisor: `pty supervisor start` runs a process that watches for sessions with `strategy=permanent` tag and restarts them on exit with exponential backoff 10 + - Add `pty tag <name> key=value` / `pty tag <name> --rm key` to set and remove tags on running or exited sessions 11 + - Add `pty supervisor start/stop/status/forget` commands 12 + - Add `pty supervisor launchd install/uninstall` for macOS auto-start 13 + - Supervision is configured entirely through tags (`strategy=permanent` in pty.toml tags or via `pty tag`) 14 + - `pty list` shows `[permanent]`, `[temporary]`, and `[failed]` markers for supervised sessions 15 + - `pty down` refuses to stop supervised sessions (use `pty supervisor forget` first) 16 + - New event types: `session_restart`, `session_failed`, `supervisor_start`, `supervisor_stop` 17 + - Atomic metadata writes (write-to-temp + rename) to prevent partial reads 18 + - Add `pty peek --wait "text"` to block until text appears on screen, with optional `-t` timeout 19 + - Add `pty peek --full` to show full scrollback (not just viewport) 20 + - Add `pty events --wait <type>` to block until a specific event type occurs 9 21 - Colorize `pty list` output: bold cyan session names, dimmed commands 22 + - Interactive TUI list shows `[permanent]`/`[temporary]`/`[failed]` markers with color 10 23 - Add `--cwd` flag to `pty run` to start a session in a specific directory without needing to `cd` first 11 24 12 25 ## 0.6.0
+29
README.md
··· 153 153 154 154 Run `pty up` in the project directory (or `pty up /path/to/project`) to start all sessions. Run `pty down` to stop them. You can also start specific sessions: `pty up dev serve`. 155 155 156 + ### Supervisor 157 + 158 + The supervisor keeps sessions alive by watching for the `strategy` tag: 159 + 160 + ```sh 161 + # Tag a session as permanent 162 + pty tag myserver strategy=permanent 163 + 164 + # Start the supervisor 165 + pty supervisor start 166 + 167 + # If myserver exits, the supervisor restarts it with exponential backoff 168 + # Max 5 restarts in 60 seconds before marking as [failed] 169 + 170 + pty supervisor status # show supervised sessions 171 + pty supervisor forget myserver # stop supervising 172 + pty supervisor stop # stop the supervisor 173 + ``` 174 + 175 + Sessions can be supervised from `pty.toml` by setting the `strategy` tag: 176 + 177 + ```toml 178 + [sessions.serve] 179 + command = "bin/serve" 180 + tags = { strategy = "permanent" } 181 + ``` 182 + 183 + For macOS auto-start on login: `pty supervisor launchd install`. 184 + 156 185 ### Plugins 157 186 158 187 Like `git`, `pty` supports extensions: if you run `pty foo` and there's a `pty-foo` executable in your `$PATH`, pty will run it with the remaining arguments. This lets you build your own subcommands without modifying pty.
+13 -4
completions/pty.bash
··· 6 6 COMPREPLY=() 7 7 cur="${COMP_WORDS[COMP_CWORD]}" 8 8 prev="${COMP_WORDS[COMP_CWORD-1]}" 9 - commands="run attach a peek send events list ls stats restart kill rm remove gc up down wrap unwrap test help" 9 + commands="run attach a peek send events list ls stats restart kill rm remove gc tag supervisor up down wrap unwrap test help" 10 10 11 11 # Complete subcommand 12 12 if [[ ${COMP_CWORD} -eq 1 ]]; then ··· 21 21 fi 22 22 23 23 case "${COMP_WORDS[1]}" in 24 - attach|a|peek|send|kill|restart|events|rm|remove|stats) 24 + attach|a|peek|send|kill|restart|events|rm|remove|stats|tag) 25 25 if [[ "${cur}" == -* ]]; then 26 26 case "${COMP_WORDS[1]}" in 27 27 attach|a) COMPREPLY=($(compgen -W "--auto-restart -r" -- "${cur}")) ;; 28 - peek) COMPREPLY=($(compgen -W "--follow -f --plain" -- "${cur}")) ;; 28 + peek) COMPREPLY=($(compgen -W "--follow -f --plain --full --wait --timeout -t" -- "${cur}")) ;; 29 29 send) COMPREPLY=($(compgen -W "--seq --with-delay" -- "${cur}")) ;; 30 30 restart) COMPREPLY=($(compgen -W "--yes -y" -- "${cur}")) ;; 31 - events) COMPREPLY=($(compgen -W "--all --recent --json" -- "${cur}")) ;; 31 + events) COMPREPLY=($(compgen -W "--all --recent --json --wait --timeout -t" -- "${cur}")) ;; 32 32 stats) COMPREPLY=($(compgen -W "--json --all" -- "${cur}")) ;; 33 33 esac 34 34 else ··· 71 71 # Before --, complete flags 72 72 if [[ "${cur}" == -* ]]; then 73 73 COMPREPLY=($(compgen -W "--detach -d --attach -a --ephemeral -e --name --cwd --tag" -- "${cur}")) 74 + fi 75 + ;; 76 + supervisor) 77 + if [[ ${COMP_CWORD} -eq 2 ]]; then 78 + COMPREPLY=($(compgen -W "start stop status forget reset launchd" -- "${cur}")) 79 + elif [[ "${COMP_WORDS[2]}" == "forget" || "${COMP_WORDS[2]}" == "reset" ]]; then 80 + COMPREPLY=($(compgen -W "${names}" -- "${cur}")) 81 + elif [[ "${COMP_WORDS[2]}" == "launchd" ]]; then 82 + COMPREPLY=($(compgen -W "install uninstall" -- "${cur}")) 74 83 fi 75 84 ;; 76 85 esac
+15
completions/pty.fish
··· 58 58 complete -c pty -n __pty_needs_command -a rm -d 'Remove an exited session' 59 59 complete -c pty -n __pty_needs_command -a remove -d 'Remove an exited session' 60 60 complete -c pty -n __pty_needs_command -a gc -d 'Remove all exited sessions' 61 + complete -c pty -n __pty_needs_command -a tag -d 'Show or modify session tags' 62 + complete -c pty -n __pty_needs_command -a supervisor -d 'Manage the session supervisor' 61 63 complete -c pty -n __pty_needs_command -a up -d 'Start sessions from pty.toml' 62 64 complete -c pty -n __pty_needs_command -a down -d 'Stop sessions from pty.toml' 63 65 complete -c pty -n __pty_needs_command -a wrap -d 'Auto-wrap a command in pty sessions' ··· 84 86 complete -c pty -n '__pty_using_command peek' -a '(__pty_sessions)' -d 'Session' 85 87 complete -c pty -n '__pty_using_command peek' -s f -l follow -d 'Follow output read-only' 86 88 complete -c pty -n '__pty_using_command peek' -l plain -d 'Output plain text without ANSI' 89 + complete -c pty -n '__pty_using_command peek' -l full -d 'Include full scrollback' 90 + complete -c pty -n '__pty_using_command peek' -l wait -x -d 'Wait until text appears' 91 + complete -c pty -n '__pty_using_command peek' -s t -l timeout -x -d 'Timeout in seconds' 87 92 88 93 # send: session names and flags 89 94 complete -c pty -n '__pty_using_command send' -a '(__pty_sessions)' -d 'Session' ··· 95 100 complete -c pty -n '__pty_using_command events' -l all -d 'Follow events from all sessions' 96 101 complete -c pty -n '__pty_using_command events' -l recent -d 'Show recent events and exit' 97 102 complete -c pty -n '__pty_using_command events' -l json -d 'Output raw JSONL' 103 + complete -c pty -n '__pty_using_command events' -l wait -x -d 'Wait for a specific event type' 104 + complete -c pty -n '__pty_using_command events' -s t -l timeout -x -d 'Timeout in seconds' 98 105 99 106 # list: flags 100 107 complete -c pty -n '__pty_using_command list' -l json -d 'Output as JSON' ··· 124 131 125 132 # unwrap: previously wrapped commands 126 133 complete -c pty -n '__pty_using_command unwrap' -a '(__pty_wrapped)' -d 'Wrapped command' 134 + 135 + # tag: session names 136 + complete -c pty -n '__pty_using_command tag' -a '(__pty_sessions)' -d 'Session' 137 + complete -c pty -n '__pty_using_command tag' -l rm -d 'Remove a tag' 138 + 139 + # supervisor: subcommands 140 + complete -c pty -n '__pty_using_command supervisor' -a 'start stop status forget reset launchd' -d 'Subcommand' 141 +
+30
completions/pty.zsh
··· 38 38 'rm:Remove an exited session' 39 39 'remove:Remove an exited session' 40 40 'gc:Remove all exited sessions' 41 + 'tag:Show or modify session tags' 42 + 'supervisor:Manage the session supervisor' 41 43 'up:Start sessions from pty.toml' 42 44 'down:Stop sessions from pty.toml' 43 45 'wrap:Auto-wrap a command in pty sessions' ··· 65 67 _arguments \ 66 68 '(-f --follow)'{-f,--follow}'[Follow output read-only]' \ 67 69 '--plain[Output plain text without ANSI codes]' \ 70 + '--full[Include full scrollback]' \ 71 + '--wait[Wait until text appears]:pattern:' \ 72 + '(-t --timeout)'{-t,--timeout}'[Timeout in seconds]:seconds:' \ 68 73 '1:session:_pty_sessions' 69 74 ;; 70 75 send) ··· 86 91 '--all[Follow events from all sessions]' \ 87 92 '--recent[Show recent events and exit]' \ 88 93 '--json[Output raw JSONL]' \ 94 + '--wait[Wait for a specific event type]:type:' \ 95 + '(-t --timeout)'{-t,--timeout}'[Timeout in seconds]:seconds:' \ 89 96 '1:session:_pty_sessions' 90 97 ;; 91 98 stats) ··· 101 108 ;; 102 109 gc) 103 110 # No arguments 111 + ;; 112 + tag) 113 + _arguments '1:session:_pty_sessions' 114 + ;; 115 + supervisor) 116 + local -a subcmds 117 + subcmds=( 118 + 'start:Start the supervisor' 119 + 'stop:Stop the supervisor' 120 + 'status:Show supervised sessions' 121 + 'forget:Stop supervising a session' 122 + 'reset:Reset a failed session for retry' 123 + 'launchd:Register/unregister with macOS launchd' 124 + ) 125 + _arguments '1:subcommand:->subcmd' '*::arg:->subargs' 126 + case $state in 127 + subcmd) _describe 'subcommand' subcmds ;; 128 + subargs) 129 + case ${words[1]} in 130 + forget|reset) _arguments '1:session:_pty_sessions' ;; 131 + launchd) _arguments '1:action:(install uninstall)' ;; 132 + esac ;; 133 + esac 104 134 ;; 105 135 wrap) 106 136 _arguments \
+593 -20
src/cli.ts
··· 14 14 validateName, 15 15 acquireLock, 16 16 releaseLock, 17 + updateTags, 18 + readMetadata, 19 + getSessionDir, 17 20 type SessionInfo, 18 21 } from "./sessions.ts"; 19 22 import { spawnDaemon, resolveCommand } from "./spawn.ts"; 20 23 import { EventFollower, readRecentEvents, formatEvent } from "./events.ts"; 21 24 import { readPtyFile, type PtySessionDef } from "./ptyfile.ts"; 25 + import { getSupervisorDir } from "./supervisor.ts"; 22 26 23 27 // Lazy-load the interactive TUI so non-interactive commands don't crash when 24 28 // the caller's cwd was deleted (the TUI module evaluates process.cwd() at load). ··· 40 44 pty attach -r <name> Attach, auto-restart if exited 41 45 pty peek <name> Print current screen and exit 42 46 pty peek --plain <name> Print current screen as plain text (no ANSI) 47 + pty peek --full <name> Print full scrollback (not just viewport) 48 + pty peek --wait "text" <name> Wait until text appears on screen 49 + pty peek --wait "text" -t 10 <name> Wait with timeout (seconds) 43 50 pty peek -f <name> Follow output read-only (Ctrl+\\ to stop) 44 51 pty send <name> "text" Send text to a session 45 52 pty send <name> --seq "text" --seq key:return Send an ordered sequence ··· 61 68 pty down <name> [<name>...] Stop specific sessions from pty.toml 62 69 pty kill <name> Kill or remove a session 63 70 pty gc Remove all exited sessions 71 + pty tag <name> Show tags on a session 72 + pty tag <name> key=value [key=value...] Set tags 73 + pty tag <name> --rm key [--rm key...] Remove tags 74 + pty supervisor start Start the session supervisor 75 + pty supervisor stop Stop the supervisor 76 + pty supervisor status Show supervised sessions 77 + pty supervisor forget <name> Stop supervising a session 78 + pty supervisor reset <name> Reset a failed session for retry 64 79 pty wrap <command> Auto-wrap a command in pty sessions 65 80 pty unwrap <command> Remove a wrap 66 81 pty wrap --list List wrapped commands ··· 241 256 case "peek": { 242 257 let follow = false; 243 258 let plain = false; 259 + let full = false; 260 + let waitPattern: string | null = null; 261 + let timeoutSec = 0; 244 262 let pi = 1; 245 263 while (pi < args.length && args[pi].startsWith("-")) { 246 - if (args[pi] === "-f" || args[pi] === "--follow") follow = true; 247 - else if (args[pi] === "--plain") plain = true; 264 + if (args[pi] === "-f" || args[pi] === "--follow") { follow = true; pi++; } 265 + else if (args[pi] === "--plain") { plain = true; pi++; } 266 + else if (args[pi] === "--full") { full = true; pi++; } 267 + else if (args[pi] === "--wait" && pi + 1 < args.length) { waitPattern = args[pi + 1]; pi += 2; } 268 + else if ((args[pi] === "-t" || args[pi] === "--timeout") && pi + 1 < args.length) { timeoutSec = parseFloat(args[pi + 1]); pi += 2; } 248 269 else break; 249 - pi++; 250 270 } 251 271 const peekName = args[pi]; 252 272 if (!peekName) { 253 - console.error("Usage: pty peek [-f] [--plain] <name>"); 273 + console.error("Usage: pty peek [-f] [--plain] [--full] [--wait <pattern>] [-t <seconds>] <name>"); 254 274 process.exit(1); 255 275 } 256 276 try { ··· 259 279 console.error(e.message); 260 280 process.exit(1); 261 281 } 262 - cmdPeek(peekName, follow, plain); 282 + if (waitPattern) { 283 + await cmdPeekWait(peekName, waitPattern, timeoutSec, plain); 284 + } else { 285 + cmdPeek(peekName, follow, plain, full); 286 + } 263 287 break; 264 288 } 265 289 ··· 328 352 let all = false; 329 353 let recent = false; 330 354 let json = false; 355 + let waitEventType: string | null = null; 356 + let eventsTimeout = 0; 331 357 let ei = 1; 332 358 while (ei < args.length && args[ei].startsWith("-")) { 333 359 if (args[ei] === "--all") { all = true; ei++; } 334 360 else if (args[ei] === "--recent") { recent = true; ei++; } 335 361 else if (args[ei] === "--json") { json = true; ei++; } 362 + else if (args[ei] === "--wait" && ei + 1 < args.length) { waitEventType = args[ei + 1]; ei += 2; } 363 + else if ((args[ei] === "-t" || args[ei] === "--timeout") && ei + 1 < args.length) { eventsTimeout = parseFloat(args[ei + 1]); ei += 2; } 336 364 else break; 337 365 } 338 366 const eventsName = args[ei]; 339 367 340 368 if (!all && !eventsName) { 341 - console.error("Usage: pty events [--all] [--recent] [--json] [<name>]"); 369 + console.error("Usage: pty events [--all] [--recent] [--json] [--wait <type>] [-t <seconds>] [<name>]"); 342 370 process.exit(1); 343 371 } 344 372 ··· 351 379 } 352 380 } 353 381 354 - await cmdEvents(eventsName ?? null, { all, recent, json }); 382 + await cmdEvents(eventsName ?? null, { all, recent, json, waitEventType, timeout: eventsTimeout }); 355 383 break; 356 384 } 357 385 ··· 407 435 break; 408 436 } 409 437 438 + case "tag": { 439 + const tagName = args[1]; 440 + if (!tagName) { 441 + console.error("Usage: pty tag <name> [key=value...] [--rm key...]"); 442 + process.exit(1); 443 + } 444 + try { 445 + validateName(tagName); 446 + } catch (e: any) { 447 + console.error(e.message); 448 + process.exit(1); 449 + } 450 + 451 + const updates: Record<string, string> = {}; 452 + const removals: string[] = []; 453 + for (let i = 2; i < args.length; i++) { 454 + if (args[i] === "--rm" && i + 1 < args.length) { 455 + removals.push(args[i + 1]); 456 + i++; 457 + } else { 458 + const eq = args[i].indexOf("="); 459 + if (eq === -1) { 460 + console.error(`Invalid tag format: "${args[i]}". Use key=value or --rm key`); 461 + process.exit(1); 462 + } 463 + updates[args[i].slice(0, eq)] = args[i].slice(eq + 1); 464 + } 465 + } 466 + 467 + // No updates or removals — show current tags 468 + if (Object.keys(updates).length === 0 && removals.length === 0) { 469 + const meta = readMetadata(tagName); 470 + if (!meta) { 471 + console.error(`Session "${tagName}" not found.`); 472 + process.exit(1); 473 + } 474 + if (!meta.tags || Object.keys(meta.tags).length === 0) { 475 + console.log(`No tags on "${tagName}".`); 476 + } else { 477 + for (const [k, v] of Object.entries(meta.tags)) { 478 + console.log(` ${k}=${v}`); 479 + } 480 + } 481 + break; 482 + } 483 + 484 + try { 485 + // Check if session is managed by a pty.toml before modifying 486 + const beforeMeta = readMetadata(tagName); 487 + const ptyfilePath = beforeMeta?.tags?.ptyfile; 488 + 489 + updateTags(tagName, updates, removals); 490 + const meta = readMetadata(tagName); 491 + if (!meta?.tags || Object.keys(meta.tags).length === 0) { 492 + console.log(`Tags cleared on "${tagName}".`); 493 + } else { 494 + console.log(`Tags on "${tagName}":`); 495 + for (const [k, v] of Object.entries(meta.tags)) { 496 + console.log(` ${k}=${v}`); 497 + } 498 + } 499 + 500 + if (ptyfilePath) { 501 + console.error(`\nWarning: this session is managed by ${ptyfilePath}`); 502 + console.error("Running 'pty up' will sync tags from the toml and may overwrite this change."); 503 + console.error("To make it permanent, edit the pty.toml file directly."); 504 + } 505 + } catch (e: any) { 506 + console.error(e.message); 507 + process.exit(1); 508 + } 509 + break; 510 + } 511 + 410 512 case "up": { 411 513 if (args[1] === "-h" || args[1] === "--help") { 412 514 console.log("Usage: pty up [dir] [name...]\n\nStart sessions defined in pty.toml."); ··· 419 521 420 522 for (const arg of upArgs) { 421 523 if (arg.startsWith("-")) break; 422 - // First arg: directory if pty.toml exists there, otherwise session name 423 524 if (!dir && names.length === 0 && hasPtyFile(arg)) { 424 525 dir = arg; 425 526 } else { ··· 470 571 break; 471 572 } 472 573 574 + case "supervisor": { 575 + const subCmd = args[1]; 576 + if (!subCmd || subCmd === "-h" || subCmd === "--help") { 577 + console.log(`Usage: 578 + pty supervisor start Start the supervisor 579 + pty supervisor stop Stop the supervisor 580 + pty supervisor status Show supervised sessions 581 + pty supervisor forget <name> Stop supervising a session 582 + pty supervisor reset <name> Reset a failed session for retry 583 + pty supervisor launchd install Register with macOS launchd 584 + pty supervisor launchd uninstall Remove from launchd`); 585 + break; 586 + } 587 + switch (subCmd) { 588 + case "start": 589 + await cmdSupervisorStart(); 590 + break; 591 + case "stop": 592 + await cmdSupervisorStop(); 593 + break; 594 + case "status": 595 + await cmdSupervisorStatus(); 596 + break; 597 + case "forget": { 598 + const forgetName = args[2]; 599 + if (!forgetName) { 600 + console.error("Usage: pty supervisor forget <name>"); 601 + process.exit(1); 602 + } 603 + await cmdSupervisorForget(forgetName); 604 + break; 605 + } 606 + case "reset": { 607 + const resetName = args[2]; 608 + if (!resetName) { 609 + console.error("Usage: pty supervisor reset <name>"); 610 + process.exit(1); 611 + } 612 + await cmdSupervisorReset(resetName); 613 + break; 614 + } 615 + case "launchd": { 616 + const launchdCmd = args[2]; 617 + if (launchdCmd === "install") { 618 + cmdSupervisorLaunchdInstall(); 619 + } else if (launchdCmd === "uninstall") { 620 + cmdSupervisorLaunchdUninstall(); 621 + } else { 622 + console.error("Usage: pty supervisor launchd install|uninstall"); 623 + process.exit(1); 624 + } 625 + break; 626 + } 627 + default: 628 + console.error(`Unknown supervisor command: ${subCmd}`); 629 + process.exit(1); 630 + } 631 + break; 632 + } 633 + 473 634 case "wrap": { 474 635 if (args[1] === "--list" || args[1] === "-l") { 475 636 cmdWrapList(); ··· 626 787 `Session "${session.name}" exited with code ${meta.exitCode ?? "unknown"}.` 627 788 ); 628 789 629 - const cmd = [meta.displayCommand, ...meta.args].join(" "); 790 + const cmd = [meta.displayCommand, ...(meta.args ?? [])].join(" "); 630 791 console.log(`Command was: ${cmd}`); 631 792 console.log(""); 632 793 ··· 652 813 }); 653 814 } 654 815 655 - function cmdPeek(name: string, follow: boolean, plain: boolean): void { 816 + async function cmdPeekWait(name: string, pattern: string, timeoutSec: number, plain: boolean): Promise<void> { 817 + const { peekScreen } = await import("./connection.ts"); 818 + const start = Date.now(); 819 + const timeoutMs = timeoutSec > 0 ? timeoutSec * 1000 : 0; 820 + 821 + while (true) { 822 + if (timeoutMs > 0 && Date.now() - start > timeoutMs) { 823 + console.error(`Timed out after ${timeoutSec}s waiting for "${pattern}".`); 824 + process.exit(1); 825 + } 826 + 827 + try { 828 + const screen = await peekScreen({ name, plain: true }); 829 + if (screen.includes(pattern)) { 830 + if (plain) { 831 + process.stdout.write(screen + "\n"); 832 + } else { 833 + const ansiScreen = await peekScreen({ name, plain: false }); 834 + process.stdout.write(ansiScreen + "\n"); 835 + } 836 + return; 837 + } 838 + } catch (err: any) { 839 + console.error(err.message); 840 + process.exit(1); 841 + } 842 + 843 + await new Promise((r) => setTimeout(r, 200)); 844 + } 845 + } 846 + 847 + function cmdPeek(name: string, follow: boolean, plain: boolean, full = false): void { 656 848 peek({ 657 849 name, 658 850 follow, 659 851 plain, 852 + full, 660 853 onDetach: () => process.exit(0), 661 854 onExit: (code) => process.exit(code), 662 855 }); ··· 671 864 status: s.status, 672 865 pid: s.pid, 673 866 command: s.metadata 674 - ? [s.metadata.displayCommand, ...s.metadata.args].join(" ") 867 + ? [s.metadata.displayCommand, ...(s.metadata.args ?? [])].join(" ") 675 868 : null, 676 869 cwd: s.metadata?.cwd ?? null, 677 870 createdAt: s.metadata?.createdAt ?? null, ··· 695 888 console.log("Active sessions:"); 696 889 for (const session of running) { 697 890 const cmd = session.metadata 698 - ? [session.metadata.displayCommand, ...session.metadata.args].join(" ") 891 + ? [session.metadata.displayCommand, ...(session.metadata.args ?? [])].join(" ") 699 892 : "unknown"; 700 893 const cwd = session.metadata?.cwd 701 894 ? shortPath(session.metadata.cwd) ··· 703 896 const tagStr = showTags && session.metadata?.tags 704 897 ? " " + Object.entries(session.metadata.tags).map(([k, v]) => `#${k}=${v}`).join(" ") 705 898 : ""; 706 - console.log(` \x1b[1;36m${session.name}\x1b[0m${tagStr} (pid: ${session.pid}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`); 899 + const marker = strategyMarker(session.metadata?.tags); 900 + console.log(` \x1b[1;36m${session.name}\x1b[0m${marker}${tagStr} (pid: ${session.pid}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`); 707 901 } 708 902 } 709 903 ··· 716 910 const ago = meta?.exitedAt ? timeAgo(new Date(meta.exitedAt)) : "unknown"; 717 911 const cwd = meta?.cwd ? shortPath(meta.cwd) : ""; 718 912 const cmd = meta 719 - ? [meta.displayCommand, ...meta.args].join(" ") 913 + ? [meta.displayCommand, ...(meta.args ?? [])].join(" ") 720 914 : ""; 721 915 const tagStr = showTags && meta?.tags 722 916 ? " " + Object.entries(meta.tags).map(([k, v]) => `#${k}=${v}`).join(" ") 723 917 : ""; 724 - console.log(` \x1b[1m${session.name}\x1b[0m${tagStr} (exited with code ${code}, ${ago}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`); 918 + const marker = strategyMarker(meta?.tags); 919 + console.log(` \x1b[1m${session.name}\x1b[0m${marker}${tagStr} (exited with code ${code}, ${ago}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`); 725 920 } 726 921 } 727 922 } ··· 830 1025 831 1026 function printStats(stats: StatsResult, meta: SessionInfo["metadata"]): void { 832 1027 const cmd = meta 833 - ? [meta.displayCommand, ...meta.args].join(" ") 1028 + ? [meta.displayCommand, ...(meta.args ?? [])].join(" ") 834 1029 : "unknown"; 835 1030 const cwd = meta?.cwd ? shortPath(meta.cwd) : "unknown"; 836 1031 ··· 893 1088 process.exit(1); 894 1089 } 895 1090 1091 + // Remove supervision tags so the supervisor doesn't restart it 1092 + const wasSupervised = session.metadata?.tags?.strategy === "permanent" || session.metadata?.tags?.strategy === "temporary"; 1093 + if (wasSupervised) { 1094 + try { 1095 + const removals = ["strategy"]; 1096 + if (session.metadata?.tags?.["supervisor.status"]) removals.push("supervisor.status"); 1097 + updateTags(name, {}, removals); 1098 + } catch {} 1099 + } 1100 + 896 1101 try { 897 1102 process.kill(session.pid, "SIGTERM"); 898 1103 console.log(`Session "${name}" killed.`); ··· 900 1105 console.error(`Failed to kill session "${name}".`); 901 1106 } 902 1107 cleanupSocket(name); 1108 + 1109 + if (wasSupervised && session.metadata?.tags?.ptyfile) { 1110 + console.error(`Note: this session is managed by ${session.metadata.tags.ptyfile}`); 1111 + console.error("The strategy tag will be restored on the next 'pty up'."); 1112 + } 903 1113 } 904 1114 905 1115 async function cmdRm(name: string): Promise<void> { ··· 933 1143 console.log(`Cleaned up ${removed.length} exited session${removed.length === 1 ? "" : "s"}.`); 934 1144 } 935 1145 1146 + async function cmdSupervisorStart(): Promise<void> { 1147 + // Run the supervisor in the foreground (not in a pty session). 1148 + // This makes it work with launchd KeepAlive since launchd owns the process. 1149 + // Use `pty events supervisor` to follow activity, `pty supervisor status` for state. 1150 + const { Supervisor } = await import("./supervisor.ts"); 1151 + 1152 + const sup = new Supervisor("supervisor"); 1153 + sup.start(); 1154 + 1155 + console.log(`[supervisor] started (pid ${process.pid})`); 1156 + console.log(`[supervisor] watching ${getSessionDir()}`); 1157 + 1158 + process.on("SIGTERM", () => { 1159 + console.log("[supervisor] stopping..."); 1160 + sup.stop(); 1161 + process.exit(0); 1162 + }); 1163 + process.on("SIGINT", () => { 1164 + console.log("[supervisor] stopping..."); 1165 + sup.stop(); 1166 + process.exit(0); 1167 + }); 1168 + 1169 + // Keep the process alive 1170 + await new Promise(() => {}); 1171 + } 1172 + 1173 + async function cmdSupervisorStop(): Promise<void> { 1174 + const pidPath = path.join(getSupervisorDir(), "supervisor.pid"); 1175 + let pid: number; 1176 + try { 1177 + pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10); 1178 + } catch { 1179 + console.error("Supervisor is not running."); 1180 + process.exit(1); 1181 + } 1182 + try { 1183 + process.kill(pid, 0); // check if alive 1184 + process.kill(pid, "SIGTERM"); 1185 + console.log("Supervisor stopped."); 1186 + } catch { 1187 + console.error("Supervisor is not running (stale pid file)."); 1188 + try { fs.unlinkSync(pidPath); } catch {} 1189 + process.exit(1); 1190 + } 1191 + } 1192 + 1193 + async function cmdSupervisorStatus(): Promise<void> { 1194 + const sessions = await listSessions(); 1195 + const supervised = sessions.filter((s) => 1196 + s.metadata?.tags?.strategy === "permanent" || s.metadata?.tags?.strategy === "temporary" 1197 + ); 1198 + 1199 + if (supervised.length === 0) { 1200 + console.log("No supervised sessions."); 1201 + return; 1202 + } 1203 + 1204 + // Try to read supervisor state for restart counts 1205 + let state: Record<string, any> = {}; 1206 + try { 1207 + const content = fs.readFileSync(path.join(getSupervisorDir(), "state.json"), "utf-8"); 1208 + state = JSON.parse(content).sessions ?? {}; 1209 + } catch {} 1210 + 1211 + let supervisorRunning = false; 1212 + try { 1213 + const pid = parseInt(fs.readFileSync(path.join(getSupervisorDir(), "supervisor.pid"), "utf-8").trim(), 10); 1214 + process.kill(pid, 0); 1215 + supervisorRunning = true; 1216 + } catch {} 1217 + console.log(`Supervisor: ${supervisorRunning ? "\x1b[32mrunning\x1b[0m" : "\x1b[31mnot running\x1b[0m"}`); 1218 + console.log(""); 1219 + 1220 + for (const s of supervised) { 1221 + const strategy = s.metadata!.tags!.strategy; 1222 + const supStatus = s.metadata?.tags?.["supervisor.status"]; 1223 + const stateInfo = state[s.name]; 1224 + const restarts = stateInfo?.restartCount ?? 0; 1225 + 1226 + let status = s.status === "running" ? "\x1b[32mrunning\x1b[0m" : "\x1b[33mexited\x1b[0m"; 1227 + if (supStatus === "failed") status = "\x1b[31mfailed\x1b[0m"; 1228 + 1229 + console.log(` \x1b[1m${s.name}\x1b[0m [${strategy}] — ${status}${restarts > 0 ? ` (${restarts} restarts)` : ""}`); 1230 + } 1231 + } 1232 + 1233 + async function cmdSupervisorForget(name: string): Promise<void> { 1234 + try { 1235 + validateName(name); 1236 + } catch (e: any) { 1237 + console.error(e.message); 1238 + process.exit(1); 1239 + } 1240 + 1241 + const meta = readMetadata(name); 1242 + if (!meta) { 1243 + console.error(`Session "${name}" not found.`); 1244 + process.exit(1); 1245 + } 1246 + 1247 + const removals: string[] = []; 1248 + if (meta.tags?.strategy) removals.push("strategy"); 1249 + if (meta.tags?.["supervisor.status"]) removals.push("supervisor.status"); 1250 + 1251 + if (removals.length === 0) { 1252 + console.log(`Session "${name}" is not supervised.`); 1253 + return; 1254 + } 1255 + 1256 + updateTags(name, {}, removals); 1257 + console.log(`Removed supervision from "${name}".`); 1258 + 1259 + if (meta.tags?.ptyfile) { 1260 + console.error(`\nWarning: this session is managed by ${meta.tags.ptyfile}`); 1261 + console.error("The strategy tag will be restored on the next 'pty up'."); 1262 + console.error("Edit the pty.toml to make this permanent."); 1263 + } 1264 + } 1265 + 1266 + async function cmdSupervisorReset(name: string): Promise<void> { 1267 + try { 1268 + validateName(name); 1269 + } catch (e: any) { 1270 + console.error(e.message); 1271 + process.exit(1); 1272 + } 1273 + 1274 + const meta = readMetadata(name); 1275 + if (!meta) { 1276 + console.error(`Session "${name}" not found.`); 1277 + process.exit(1); 1278 + } 1279 + 1280 + if (meta.tags?.["supervisor.status"] !== "failed") { 1281 + console.log(`Session "${name}" is not in failed state.`); 1282 + return; 1283 + } 1284 + 1285 + // Remove the failed tag 1286 + updateTags(name, {}, ["supervisor.status"]); 1287 + 1288 + // Reset restart counter in supervisor state 1289 + const statePath = path.join(getSupervisorDir(), "state.json"); 1290 + try { 1291 + const content = fs.readFileSync(statePath, "utf-8"); 1292 + const state = JSON.parse(content); 1293 + if (state.sessions?.[name]) { 1294 + state.sessions[name].restartCount = 0; 1295 + state.sessions[name].restartWindowStart = 0; 1296 + state.sessions[name].nextBackoffMs = 1000; 1297 + state.sessions[name].failed = false; 1298 + const tmp = statePath + ".tmp"; 1299 + fs.writeFileSync(tmp, JSON.stringify(state, null, 2)); 1300 + fs.renameSync(tmp, statePath); 1301 + } 1302 + } catch {} 1303 + 1304 + console.log(`Reset "${name}". The supervisor will try restarting it.`); 1305 + } 1306 + 1307 + function cmdSupervisorLaunchdInstall(): void { 1308 + const launchdDir = path.join(os.homedir(), ".local", "pty", "launchd"); 1309 + const bundlePath = path.join(launchdDir, "supervisor.bundle.js"); 1310 + const logPath = path.join(os.homedir(), ".local", "state", "pty", "supervisor.log"); 1311 + const plistDir = path.join(os.homedir(), "Library", "LaunchAgents"); 1312 + const plistPath = path.join(plistDir, "com.myobie.pty.supervisor.plist"); 1313 + 1314 + // Stop existing supervisor if running 1315 + const pidPath = path.join(getSupervisorDir(), "supervisor.pid"); 1316 + try { 1317 + const pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10); 1318 + try { process.kill(pid, "SIGTERM"); } catch {} 1319 + try { fs.unlinkSync(pidPath); } catch {} 1320 + console.log("Stopped existing supervisor."); 1321 + } catch {} 1322 + 1323 + // Unload existing plist if present 1324 + if (fs.existsSync(plistPath)) { 1325 + spawnSync("launchctl", ["unload", plistPath], { encoding: "utf-8" }); 1326 + } 1327 + 1328 + // Bundle the supervisor into a single portable JS file 1329 + const distDir = path.join( 1330 + import.meta.dirname ?? path.dirname(new URL(import.meta.url).pathname), 1331 + "..", "dist" 1332 + ); 1333 + const entryPoint = path.join(distDir, "supervisor-entry.js"); 1334 + 1335 + console.log("Bundling supervisor..."); 1336 + const serverModule = path.join(distDir, "server.js"); 1337 + const esbuildResult = spawnSync("npx", ["esbuild", entryPoint, "--bundle", "--platform=node", "--format=esm", `--outfile=${bundlePath}`, `--define:SERVER_MODULE_PATH="${serverModule.replace(/\\/g, "\\\\")}"`], { 1338 + encoding: "utf-8", 1339 + cwd: distDir, 1340 + }); 1341 + if (esbuildResult.status !== 0) { 1342 + console.error(`Failed to bundle supervisor: ${esbuildResult.stderr}`); 1343 + process.exit(1); 1344 + } 1345 + 1346 + // Use absolute path to current node binary — no PATH dependency 1347 + const nodeBin = process.execPath; 1348 + 1349 + const plist = `<?xml version="1.0" encoding="UTF-8"?> 1350 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 1351 + <plist version="1.0"> 1352 + <dict> 1353 + <key>Label</key> 1354 + <string>com.myobie.pty.supervisor</string> 1355 + <key>ProgramArguments</key> 1356 + <array> 1357 + <string>${nodeBin}</string> 1358 + <string>${bundlePath}</string> 1359 + </array> 1360 + <key>StandardOutPath</key> 1361 + <string>${logPath}</string> 1362 + <key>StandardErrorPath</key> 1363 + <string>${logPath}</string> 1364 + <key>RunAtLoad</key> 1365 + <true/> 1366 + <key>KeepAlive</key> 1367 + <true/> 1368 + <key>ThrottleInterval</key> 1369 + <integer>5</integer> 1370 + </dict> 1371 + </plist> 1372 + `; 1373 + 1374 + fs.mkdirSync(launchdDir, { recursive: true }); 1375 + fs.mkdirSync(plistDir, { recursive: true }); 1376 + fs.writeFileSync(plistPath, plist); 1377 + 1378 + const result = spawnSync("launchctl", ["load", plistPath], { encoding: "utf-8" }); 1379 + if (result.status !== 0) { 1380 + console.error(`Failed to load plist: ${result.stderr}`); 1381 + process.exit(1); 1382 + } 1383 + 1384 + console.log(`Bundle: ${bundlePath}`); 1385 + console.log(`Plist: ${plistPath}`); 1386 + console.log(`Log: ${logPath}`); 1387 + console.log(`Node: ${nodeBin}`); 1388 + console.log("Supervisor will start on login and restart if it exits."); 1389 + } 1390 + 1391 + function cmdSupervisorLaunchdUninstall(): void { 1392 + const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", "com.myobie.pty.supervisor.plist"); 1393 + const launchdDir = path.join(os.homedir(), ".local", "pty", "launchd"); 1394 + 1395 + if (!fs.existsSync(plistPath)) { 1396 + console.error("Supervisor is not registered with launchd."); 1397 + process.exit(1); 1398 + } 1399 + 1400 + spawnSync("launchctl", ["unload", plistPath], { encoding: "utf-8" }); 1401 + try { fs.unlinkSync(plistPath); } catch {} 1402 + 1403 + // Clean up bundled files 1404 + try { fs.rmSync(launchdDir, { recursive: true, force: true }); } catch {} 1405 + 1406 + // Stop supervisor if running 1407 + const pidPath = path.join(getSupervisorDir(), "supervisor.pid"); 1408 + try { 1409 + const pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10); 1410 + try { process.kill(pid, "SIGTERM"); } catch {} 1411 + try { fs.unlinkSync(pidPath); } catch {} 1412 + } catch {} 1413 + 1414 + console.log("Supervisor removed from launchd."); 1415 + console.log(`Cleaned up ${launchdDir}`); 1416 + } 1417 + 936 1418 function hasPtyFile(dir: string): boolean { 937 1419 try { 938 1420 return fs.statSync(path.join(path.resolve(dir), "pty.toml")).isFile(); ··· 971 1453 972 1454 for (const sess of sessions) { 973 1455 if (runningNames.has(sess.name)) { 974 - console.log(` ● ${sess.name} (already running)`); 1456 + // Sync tags from toml to the running session (including ptyfile metadata) 1457 + const currentMeta = readMetadata(sess.name); 1458 + const tomlPath = path.join(ptyFile.dir, "pty.toml"); 1459 + const tomlTags = { ...sess.tags, ptyfile: tomlPath, "ptyfile.session": sess.shortName }; 1460 + const currentTags = currentMeta?.tags ?? {}; 1461 + const updates: Record<string, string> = {}; 1462 + for (const [k, v] of Object.entries(tomlTags)) { 1463 + if (currentTags[k] !== v) updates[k] = v; 1464 + } 1465 + if (Object.keys(updates).length > 0) { 1466 + try { 1467 + updateTags(sess.name, updates); 1468 + const changed = Object.entries(updates).map(([k, v]) => `${k}=${v}`).join(", "); 1469 + console.log(` ● ${sess.name} (already running, updated tags: ${changed})`); 1470 + } catch { 1471 + console.log(` ● ${sess.name} (already running)`); 1472 + } 1473 + } else { 1474 + console.log(` ● ${sess.name} (already running)`); 1475 + } 975 1476 skipped++; 976 1477 continue; 977 1478 } ··· 983 1484 } 984 1485 985 1486 try { 1487 + const tomlPath = path.join(ptyFile.dir, "pty.toml"); 1488 + const tags = { 1489 + ...sess.tags, 1490 + ptyfile: tomlPath, 1491 + "ptyfile.session": sess.shortName, 1492 + }; 986 1493 await spawnDaemon({ 987 1494 name: sess.name, 988 1495 command: "/bin/sh", 989 1496 args: ["-c", sess.command], 990 1497 displayCommand: sess.command, 991 1498 cwd: ptyFile.dir, 992 - tags: sess.tags, 1499 + tags, 993 1500 }); 994 1501 console.log(` ● ${sess.name} (started)`); 995 1502 started++; ··· 1027 1534 const existingSession = existing.find((s) => s.name === sess.name); 1028 1535 if (!existingSession) continue; 1029 1536 1537 + // Remove supervision tags so the supervisor doesn't restart it 1538 + const wasSupervised = existingSession.metadata?.tags?.strategy === "permanent" || existingSession.metadata?.tags?.strategy === "temporary"; 1539 + if (wasSupervised) { 1540 + try { 1541 + const removals = ["strategy"]; 1542 + if (existingSession.metadata?.tags?.["supervisor.status"]) removals.push("supervisor.status"); 1543 + updateTags(sess.name, {}, removals); 1544 + } catch {} 1545 + } 1546 + 1030 1547 if (existingSession.status === "running" && existingSession.pid) { 1031 1548 try { 1032 1549 process.kill(existingSession.pid, "SIGTERM"); 1033 - console.log(` ○ ${sess.name} (stopped)`); 1550 + console.log(` ○ ${sess.name} (stopped${wasSupervised ? ", removed from supervision" : ""})`); 1034 1551 stopped++; 1035 1552 } catch { 1036 1553 console.error(` ✗ ${sess.name}: failed to stop`); ··· 1048 1565 } else { 1049 1566 console.log(`Stopped ${stopped} session${stopped === 1 ? "" : "s"}.`); 1050 1567 } 1568 + 1569 + // Warn if any stopped sessions are toml-managed 1570 + const anyTomlManaged = sessions.some((sess) => { 1571 + const existingSession = existing.find((s) => s.name === sess.name); 1572 + return existingSession?.metadata?.tags?.ptyfile; 1573 + }); 1574 + if (anyTomlManaged && stopped > 0) { 1575 + console.error("\nNote: strategy tags will be restored on the next 'pty up'."); 1576 + } 1051 1577 } 1052 1578 1053 1579 async function cmdRestart(name: string, force = false): Promise<void> { ··· 1095 1621 1096 1622 async function cmdEvents( 1097 1623 name: string | null, 1098 - opts: { all: boolean; recent: boolean; json: boolean } 1624 + opts: { all: boolean; recent: boolean; json: boolean; waitEventType: string | null; timeout: number } 1099 1625 ): Promise<void> { 1100 1626 if (opts.recent) { 1101 1627 if (!name) { ··· 1122 1648 } 1123 1649 } 1124 1650 1651 + // Wait mode: block until a specific event type appears 1652 + if (opts.waitEventType) { 1653 + if (!name) { 1654 + console.error("--wait requires a session name."); 1655 + process.exit(1); 1656 + } 1657 + const timeoutMs = opts.timeout > 0 ? opts.timeout * 1000 : 0; 1658 + const start = Date.now(); 1659 + 1660 + return new Promise<void>((resolve) => { 1661 + const follower = new EventFollower({ 1662 + names: [name!], 1663 + onEvent: (event) => { 1664 + if (event.type === opts.waitEventType) { 1665 + console.log(opts.json ? JSON.stringify(event) : formatEvent(event)); 1666 + follower.stop(); 1667 + resolve(); 1668 + } 1669 + }, 1670 + }); 1671 + 1672 + follower.start(); 1673 + 1674 + if (timeoutMs > 0) { 1675 + setTimeout(() => { 1676 + follower.stop(); 1677 + console.error(`Timed out after ${opts.timeout}s waiting for "${opts.waitEventType}" event.`); 1678 + process.exit(1); 1679 + }, timeoutMs); 1680 + } 1681 + 1682 + process.on("SIGINT", () => { 1683 + follower.stop(); 1684 + process.exit(0); 1685 + }); 1686 + }); 1687 + } 1688 + 1125 1689 const follower = new EventFollower({ 1126 1690 names: opts.all ? undefined : name ? [name] : undefined, 1127 1691 onEvent: (event) => { ··· 1165 1729 rl.close(); 1166 1730 return answer; 1167 1731 }); 1732 + } 1733 + 1734 + function strategyMarker(tags?: Record<string, string>): string { 1735 + if (!tags) return ""; 1736 + const supStatus = tags["supervisor.status"]; 1737 + if (supStatus === "failed") return " \x1b[31m[failed]\x1b[0m"; 1738 + if (tags.strategy === "permanent") return " \x1b[33m[permanent]\x1b[0m"; 1739 + if (tags.strategy === "temporary") return " \x1b[2m[temporary]\x1b[0m"; 1740 + return ""; 1168 1741 } 1169 1742 1170 1743 function shortPath(p: string): string {
+3 -1
src/client-api.ts
··· 3 3 4 4 // Session management 5 5 export { 6 - listSessions, getSession, gc, validateName, 6 + listSessions, getSession, gc, validateName, updateTags, 7 7 getSessionDir, getSocketPath, 8 8 cleanupSocket, cleanupAll, 9 9 type SessionInfo, type SessionMetadata, ··· 33 33 type EventRecord, type EventBase, 34 34 type BellEvent, type TitleChangeEvent, type NotificationEvent, 35 35 type FocusRequestEvent, type CursorVisibleEvent, 36 + type SessionRestartEvent, type SessionFailedEvent, 37 + type SupervisorStartEvent, type SupervisorStopEvent, 36 38 type FollowerOptions, 37 39 } from "./events.ts"; 38 40
+2 -1
src/client.ts
··· 59 59 name: string; 60 60 follow?: boolean; // If true, stay connected and stream (like tail -f). If false, print screen and exit. 61 61 plain?: boolean; // If true, output plain text without ANSI codes. 62 + full?: boolean; // If true, include full scrollback, not just the viewport. 62 63 onExit?: (code: number) => void; 63 64 onDetach?: () => void; 64 65 } ··· 72 73 const follow = options.follow ?? false; 73 74 74 75 socket.on("connect", () => { 75 - socket.write(encodePeek(options.plain)); 76 + socket.write(encodePeek(options.plain, options.full)); 76 77 77 78 if (follow) { 78 79 // In follow mode, Ctrl+\ detaches
+2 -1
src/connection.ts
··· 28 28 export interface PeekScreenOptions { 29 29 name: string; 30 30 plain?: boolean; 31 + full?: boolean; 31 32 } 32 33 33 34 /** ··· 183 184 const socket = net.createConnection(socketPath); 184 185 185 186 socket.on("connect", () => { 186 - socket.write(encodePeek(options.plain)); 187 + socket.write(encodePeek(options.plain, options.full)); 187 188 }); 188 189 189 190 socket.on("data", (raw: Buffer) => {
+37 -1
src/events.ts
··· 8 8 NOTIFICATION: "notification", 9 9 FOCUS_REQUEST: "focus_request", 10 10 CURSOR_VISIBLE: "cursor_visible", 11 + SESSION_RESTART: "session_restart", 12 + SESSION_FAILED: "session_failed", 13 + SUPERVISOR_START: "supervisor_start", 14 + SUPERVISOR_STOP: "supervisor_stop", 11 15 } as const; 12 16 13 17 export type EventType = (typeof EventType)[keyof typeof EventType]; ··· 42 46 type: "cursor_visible"; 43 47 } 44 48 49 + export interface SessionRestartEvent extends EventBase { 50 + type: "session_restart"; 51 + restartCount: number; 52 + backoffMs: number; 53 + } 54 + 55 + export interface SessionFailedEvent extends EventBase { 56 + type: "session_failed"; 57 + restartCount: number; 58 + reason: string; 59 + } 60 + 61 + export interface SupervisorStartEvent extends EventBase { 62 + type: "supervisor_start"; 63 + } 64 + 65 + export interface SupervisorStopEvent extends EventBase { 66 + type: "supervisor_stop"; 67 + } 68 + 45 69 export type EventRecord = 46 70 | BellEvent 47 71 | TitleChangeEvent 48 72 | NotificationEvent 49 73 | FocusRequestEvent 50 - | CursorVisibleEvent; 74 + | CursorVisibleEvent 75 + | SessionRestartEvent 76 + | SessionFailedEvent 77 + | SupervisorStartEvent 78 + | SupervisorStopEvent; 51 79 52 80 const MAX_LINES = 1000; 53 81 const KEEP_LINES = 500; ··· 249 277 return `${prefix} focus requested`; 250 278 case "cursor_visible": 251 279 return `${prefix} cursor restored`; 280 + case "session_restart": 281 + return `${prefix} restarted (attempt ${event.restartCount}, backoff ${event.backoffMs}ms)`; 282 + case "session_failed": 283 + return `${prefix} failed — ${event.reason}`; 284 + case "supervisor_start": 285 + return `${prefix} supervisor started`; 286 + case "supervisor_stop": 287 + return `${prefix} supervisor stopped`; 252 288 } 253 289 }
+3 -2
src/protocol.ts
··· 56 56 return encodePacket(MessageType.EXIT, payload); 57 57 } 58 58 59 - export function encodePeek(plain = false): Buffer { 59 + export function encodePeek(plain = false, full = false): Buffer { 60 60 const payload = Buffer.alloc(1); 61 - payload.writeUInt8(plain ? 1 : 0, 0); 61 + // Bit 0: plain, Bit 1: full scrollback 62 + payload.writeUInt8((plain ? 1 : 0) | (full ? 2 : 0), 0); 62 63 return encodePacket(MessageType.PEEK, payload); 63 64 } 64 65
+24 -11
src/server.ts
··· 387 387 388 388 case MessageType.PEEK: { 389 389 client.readonly = true; 390 - const plain = packet.payload.length > 0 && packet.payload.readUInt8(0) === 1; 390 + const flags = packet.payload.length > 0 ? packet.payload.readUInt8(0) : 0; 391 + const plain = (flags & 1) !== 0; 392 + const full = (flags & 2) !== 0; 391 393 392 394 if (plain) { 393 - socket.write(encodeScreen(this.getPlainScreen())); 395 + socket.write(encodeScreen(full ? this.getFullPlainScreen() : this.getPlainScreen())); 394 396 } else { 395 - // Send current screen state (same as ATTACH) 396 - const peekScreen = this.getModePrefix() + this.serialize.serialize(); 397 + // scrollback: 0 for viewport only, omit for full scrollback 398 + const serializeOpts = full ? undefined : { scrollback: 0 }; 399 + const peekScreen = this.getModePrefix() + this.serialize.serialize(serializeOpts); 397 400 socket.write(encodeScreen(peekScreen)); 398 401 } 399 402 ··· 563 566 } 564 567 565 568 private getPlainScreen(): string { 569 + // Viewport only: last `rows` lines (where the cursor is) 566 570 const buffer = this.terminal.buffer.active; 567 571 const lines: string[] = []; 568 - for (let i = 0; i < buffer.length; i++) { 572 + const start = Math.max(0, buffer.baseY); 573 + const end = buffer.length; 574 + for (let i = start; i < end; i++) { 569 575 const line = buffer.getLine(i); 570 - if (line) { 571 - lines.push(line.translateToString(true)); 572 - } 576 + if (line) lines.push(line.translateToString(true)); 573 577 } 574 - // Trim trailing empty lines 575 - while (lines.length > 0 && lines[lines.length - 1] === "") { 576 - lines.pop(); 578 + while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); 579 + return lines.join("\n"); 580 + } 581 + 582 + private getFullPlainScreen(): string { 583 + // Full scrollback + viewport 584 + const buffer = this.terminal.buffer.active; 585 + const lines: string[] = []; 586 + for (let i = 0; i < buffer.length; i++) { 587 + const line = buffer.getLine(i); 588 + if (line) lines.push(line.translateToString(true)); 577 589 } 590 + while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); 578 591 return lines.join("\n"); 579 592 } 580 593
+29 -1
src/sessions.ts
··· 69 69 70 70 export function writeMetadata(name: string, metadata: SessionMetadata): void { 71 71 ensureSessionDir(); 72 - fs.writeFileSync(getMetadataPath(name), JSON.stringify(metadata, null, 2)); 72 + const target = getMetadataPath(name); 73 + const tmp = target + ".tmp"; 74 + fs.writeFileSync(tmp, JSON.stringify(metadata, null, 2)); 75 + fs.renameSync(tmp, target); 76 + } 77 + 78 + /** Update tags on an existing session. Performs an atomic read-modify-write. */ 79 + export function updateTags( 80 + name: string, 81 + updates: Record<string, string>, 82 + removals: string[] = [], 83 + ): void { 84 + const metadata = readMetadata(name); 85 + if (!metadata) { 86 + throw new Error(`Session "${name}" not found.`); 87 + } 88 + const tags = { ...(metadata.tags ?? {}) }; 89 + for (const [k, v] of Object.entries(updates)) { 90 + tags[k] = v; 91 + } 92 + for (const k of removals) { 93 + delete tags[k]; 94 + } 95 + if (Object.keys(tags).length > 0) { 96 + metadata.tags = tags; 97 + } else { 98 + delete metadata.tags; 99 + } 100 + writeMetadata(name, metadata); 73 101 } 74 102 75 103 export function readMetadata(name: string): SessionMetadata | null {
+5 -1
src/spawn.ts
··· 7 7 8 8 const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 9 10 + /** Allow overriding the server module path (used by the bundled supervisor). */ 11 + let _serverModulePath: string | null = null; 12 + export function setServerModulePath(p: string): void { _serverModulePath = p; } 13 + 10 14 export interface SpawnDaemonOptions { 11 15 name: string; 12 16 command: string; ··· 24 28 const rows = options.rows ?? stdout.rows ?? 24; 25 29 const cols = options.cols ?? stdout.columns ?? 80; 26 30 27 - const serverModule = path.join(__dirname, "server.js"); 31 + const serverModule = _serverModulePath ?? path.join(__dirname, "server.js"); 28 32 const config = JSON.stringify({ 29 33 name: options.name, 30 34 command: options.command,
+30
src/supervisor-entry.ts
··· 1 + // Standalone entry point for the supervisor. 2 + // Bundled by `pty supervisor launchd install` into a single portable JS file. 3 + // SERVER_MODULE_PATH is injected at bundle time by esbuild --define. 4 + 5 + import { Supervisor } from "./supervisor.ts"; 6 + import { getSessionDir } from "./sessions.ts"; 7 + import { setServerModulePath } from "./spawn.ts"; 8 + 9 + declare const SERVER_MODULE_PATH: string; 10 + if (typeof SERVER_MODULE_PATH !== "undefined") { 11 + setServerModulePath(SERVER_MODULE_PATH); 12 + } 13 + 14 + const supervisor = new Supervisor("supervisor"); 15 + supervisor.start(); 16 + 17 + console.log(`[supervisor] started (pid ${process.pid})`); 18 + console.log(`[supervisor] watching ${getSessionDir()}`); 19 + 20 + process.on("SIGTERM", () => { 21 + console.log("[supervisor] stopping..."); 22 + supervisor.stop(); 23 + process.exit(0); 24 + }); 25 + 26 + process.on("SIGINT", () => { 27 + console.log("[supervisor] stopping..."); 28 + supervisor.stop(); 29 + process.exit(0); 30 + });
+456
src/supervisor.ts
··· 1 + import * as fs from "node:fs"; 2 + import * as path from "node:path"; 3 + import { 4 + getSessionDir, ensureSessionDir, readMetadata, cleanupAll, 5 + acquireLock, releaseLock, updateTags, 6 + type SessionMetadata, 7 + } from "./sessions.ts"; 8 + import { spawnDaemon } from "./spawn.ts"; 9 + import { readPtyFile } from "./ptyfile.ts"; 10 + import { EventWriter, EventType, type EventRecord } from "./events.ts"; 11 + 12 + /** Supervisor state lives in its own subdirectory to avoid polluting the session dir. */ 13 + export function getSupervisorDir(): string { 14 + const dir = path.join(getSessionDir(), "supervisor"); 15 + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); 16 + return dir; 17 + } 18 + 19 + const MAX_RESTARTS = 5; 20 + const RESTART_WINDOW_MS = 60_000; 21 + const INITIAL_BACKOFF_MS = 1_000; 22 + const MAX_BACKOFF_MS = 16_000; 23 + const BACKOFF_MULTIPLIER = 2; 24 + const SCAN_INTERVAL_MS = 10_000; 25 + const TEMPORARY_CLEANUP_DELAY_MS = 1_000; 26 + 27 + interface SupervisedSession { 28 + name: string; 29 + strategy: "permanent" | "temporary"; 30 + restartCount: number; 31 + restartWindowStart: number; 32 + lastRestartAt: number; 33 + nextBackoffMs: number; 34 + failed: boolean; 35 + pendingTimer: ReturnType<typeof setTimeout> | null; 36 + } 37 + 38 + interface PersistedState { 39 + sessions: Record<string, { 40 + restartCount: number; 41 + restartWindowStart: number; 42 + lastRestartAt: number; 43 + nextBackoffMs: number; 44 + failed: boolean; 45 + }>; 46 + savedAt: string; 47 + } 48 + 49 + export class Supervisor { 50 + private sessions = new Map<string, SupervisedSession>(); 51 + private dirWatcher: fs.FSWatcher | null = null; 52 + private scanInterval: ReturnType<typeof setInterval> | null = null; 53 + private eventWriter: EventWriter; 54 + private stopping = false; 55 + private lockAcquired = false; 56 + 57 + constructor(private supervisorName: string) { 58 + this.eventWriter = new EventWriter(supervisorName); 59 + } 60 + 61 + start(): void { 62 + ensureSessionDir(); 63 + 64 + // Acquire lock to prevent multiple supervisors 65 + if (!acquireLock("supervisor")) { 66 + console.error("[supervisor] another supervisor is already running"); 67 + process.exit(1); 68 + } 69 + this.lockAcquired = true; 70 + 71 + // Write PID file so `pty supervisor stop` can find us 72 + const pidPath = path.join(getSupervisorDir(), "supervisor.pid"); 73 + fs.writeFileSync(pidPath, process.pid.toString()); 74 + 75 + this.loadState(); 76 + this.scanAllSessions(); 77 + this.startWatching(); 78 + 79 + this.scanInterval = setInterval(() => { 80 + if (!this.stopping) this.scanAllSessions(); 81 + }, SCAN_INTERVAL_MS); 82 + 83 + this.emitEvent(EventType.SUPERVISOR_START); 84 + } 85 + 86 + stop(): void { 87 + this.stopping = true; 88 + 89 + for (const tracked of this.sessions.values()) { 90 + if (tracked.pendingTimer) clearTimeout(tracked.pendingTimer); 91 + } 92 + 93 + this.dirWatcher?.close(); 94 + this.dirWatcher = null; 95 + 96 + if (this.scanInterval) { 97 + clearInterval(this.scanInterval); 98 + this.scanInterval = null; 99 + } 100 + 101 + this.persistState(); 102 + this.emitEvent(EventType.SUPERVISOR_STOP); 103 + 104 + // Clean up PID file 105 + try { fs.unlinkSync(path.join(getSupervisorDir(), "supervisor.pid")); } catch {} 106 + 107 + if (this.lockAcquired) { 108 + releaseLock("supervisor"); 109 + } 110 + } 111 + 112 + private scanAllSessions(): void { 113 + const sessionDir = getSessionDir(); 114 + let entries: string[]; 115 + try { 116 + entries = fs.readdirSync(sessionDir); 117 + } catch { 118 + return; 119 + } 120 + 121 + const jsonFiles = entries.filter((e) => e.endsWith(".json")); 122 + const seenNames = new Set<string>(); 123 + 124 + for (const file of jsonFiles) { 125 + const name = file.replace(/\.json$/, ""); 126 + if (name === this.supervisorName) continue; 127 + seenNames.add(name); 128 + this.evaluateSession(name); 129 + } 130 + 131 + // Remove tracked sessions whose metadata no longer exists 132 + for (const name of this.sessions.keys()) { 133 + if (!seenNames.has(name)) { 134 + const tracked = this.sessions.get(name)!; 135 + if (tracked.pendingTimer) clearTimeout(tracked.pendingTimer); 136 + this.sessions.delete(name); 137 + } 138 + } 139 + } 140 + 141 + private evaluateSession(name: string): void { 142 + const metadata = readMetadata(name); 143 + if (!metadata) return; 144 + 145 + const strategy = metadata.tags?.strategy; 146 + 147 + // No strategy tag — stop tracking if previously tracked 148 + if (strategy !== "permanent" && strategy !== "temporary") { 149 + const existing = this.sessions.get(name); 150 + if (existing) { 151 + if (existing.pendingTimer) clearTimeout(existing.pendingTimer); 152 + this.sessions.delete(name); 153 + console.log(`[supervisor] stopped tracking ${name} (strategy removed)`); 154 + } 155 + return; 156 + } 157 + 158 + // Check both metadata and whether the process is actually alive. 159 + // If the daemon was killed externally, exitedAt may not be set. 160 + let isExited = !!metadata.exitedAt; 161 + if (!isExited) { 162 + const sockPath = path.join(getSessionDir(), `${name}.sock`); 163 + const pidPath = path.join(getSessionDir(), `${name}.pid`); 164 + try { 165 + const pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10); 166 + process.kill(pid, 0); // throws if not alive 167 + } catch { 168 + // Process is dead but metadata doesn't know — treat as exited 169 + if (!fs.existsSync(sockPath)) { 170 + isExited = true; 171 + } 172 + } 173 + } 174 + 175 + if (strategy === "permanent") { 176 + if (!this.sessions.has(name)) { 177 + this.sessions.set(name, { 178 + name, 179 + strategy: "permanent", 180 + restartCount: 0, 181 + restartWindowStart: 0, 182 + lastRestartAt: 0, 183 + nextBackoffMs: INITIAL_BACKOFF_MS, 184 + failed: false, 185 + pendingTimer: null, 186 + }); 187 + console.log(`[supervisor] tracking ${name} (permanent)`); 188 + } 189 + 190 + const tracked = this.sessions.get(name)!; 191 + tracked.strategy = "permanent"; 192 + 193 + if (isExited && !tracked.failed && !tracked.pendingTimer) { 194 + this.scheduleRestart(name); 195 + } 196 + 197 + // If it's running, reset the restart window if enough time has passed 198 + if (!isExited && tracked.restartWindowStart > 0) { 199 + const elapsed = Date.now() - tracked.restartWindowStart; 200 + if (elapsed > RESTART_WINDOW_MS) { 201 + tracked.restartCount = 0; 202 + tracked.restartWindowStart = 0; 203 + tracked.nextBackoffMs = INITIAL_BACKOFF_MS; 204 + } 205 + } 206 + } 207 + 208 + if (strategy === "temporary") { 209 + if (isExited) { 210 + // Schedule cleanup 211 + if (!this.sessions.has(name) || !this.sessions.get(name)!.pendingTimer) { 212 + console.log(`[supervisor] cleaning up temporary session ${name}`); 213 + const timer = setTimeout(() => { 214 + cleanupAll(name); 215 + this.sessions.delete(name); 216 + console.log(`[supervisor] removed temporary session ${name}`); 217 + }, TEMPORARY_CLEANUP_DELAY_MS); 218 + this.sessions.set(name, { 219 + name, 220 + strategy: "temporary", 221 + restartCount: 0, 222 + restartWindowStart: 0, 223 + lastRestartAt: 0, 224 + nextBackoffMs: 0, 225 + failed: false, 226 + pendingTimer: timer, 227 + }); 228 + } 229 + } else if (!this.sessions.has(name)) { 230 + this.sessions.set(name, { 231 + name, 232 + strategy: "temporary", 233 + restartCount: 0, 234 + restartWindowStart: 0, 235 + lastRestartAt: 0, 236 + nextBackoffMs: 0, 237 + failed: false, 238 + pendingTimer: null, 239 + }); 240 + console.log(`[supervisor] tracking ${name} (temporary)`); 241 + } 242 + } 243 + } 244 + 245 + private scheduleRestart(name: string): void { 246 + const tracked = this.sessions.get(name); 247 + if (!tracked || tracked.strategy !== "permanent") return; 248 + 249 + const now = Date.now(); 250 + 251 + // Reset window if expired 252 + if (tracked.restartWindowStart > 0 && now - tracked.restartWindowStart > RESTART_WINDOW_MS) { 253 + tracked.restartCount = 0; 254 + tracked.restartWindowStart = 0; 255 + tracked.nextBackoffMs = INITIAL_BACKOFF_MS; 256 + } 257 + 258 + // Check restart limit 259 + if (tracked.restartCount >= MAX_RESTARTS) { 260 + this.markFailed(name); 261 + return; 262 + } 263 + 264 + // Start window on first restart 265 + if (tracked.restartWindowStart === 0) { 266 + tracked.restartWindowStart = now; 267 + } 268 + 269 + const backoff = tracked.nextBackoffMs; 270 + console.log(`[supervisor] scheduling restart for ${name} in ${backoff}ms (attempt ${tracked.restartCount + 1}/${MAX_RESTARTS})`); 271 + 272 + tracked.pendingTimer = setTimeout(() => { 273 + tracked.pendingTimer = null; 274 + this.doRestart(name).catch((err) => { 275 + console.error(`[supervisor] restart failed for ${name}: ${err.message}`); 276 + tracked.restartCount++; 277 + tracked.nextBackoffMs = Math.min(tracked.nextBackoffMs * BACKOFF_MULTIPLIER, MAX_BACKOFF_MS); 278 + this.persistState(); 279 + // Try again if under limit 280 + if (tracked.restartCount < MAX_RESTARTS) { 281 + this.scheduleRestart(name); 282 + } else { 283 + this.markFailed(name); 284 + } 285 + }); 286 + }, backoff); 287 + } 288 + 289 + private async doRestart(name: string): Promise<void> { 290 + if (this.stopping) return; 291 + 292 + // Re-read metadata to verify session is still exited and supervised 293 + const metadata = readMetadata(name); 294 + if (!metadata) return; 295 + if (metadata.tags?.strategy !== "permanent") return; 296 + 297 + // Check if actually still dead (exitedAt may be missing if killed externally) 298 + if (!metadata.exitedAt) { 299 + const pidPath = path.join(getSessionDir(), `${name}.pid`); 300 + try { 301 + const pid = parseInt(fs.readFileSync(pidPath, "utf-8").trim(), 10); 302 + process.kill(pid, 0); 303 + return; // still alive, skip restart 304 + } catch { 305 + // dead — proceed with restart 306 + } 307 + } 308 + 309 + const tracked = this.sessions.get(name); 310 + if (!tracked) return; 311 + 312 + // If session was started from a pty.toml, re-read it for the latest config 313 + let command = metadata.command; 314 + let args = metadata.args; 315 + let displayCommand = metadata.displayCommand; 316 + let cwd = metadata.cwd; 317 + let tags = metadata.tags; 318 + 319 + const ptyfilePath = metadata.tags?.ptyfile; 320 + const ptyfileSession = metadata.tags?.["ptyfile.session"]; 321 + if (ptyfilePath && ptyfileSession) { 322 + try { 323 + const dir = path.dirname(ptyfilePath); 324 + const ptyFile = readPtyFile(dir); 325 + const sessDef = ptyFile.sessions.find((s) => s.shortName === ptyfileSession); 326 + if (sessDef) { 327 + command = "/bin/sh"; 328 + args = ["-c", sessDef.command]; 329 + displayCommand = sessDef.command; 330 + cwd = ptyFile.dir; 331 + tags = { ...sessDef.tags, ptyfile: ptyfilePath, "ptyfile.session": ptyfileSession }; 332 + console.log(`[supervisor] re-read pty.toml for ${name}`); 333 + } 334 + } catch (err: any) { 335 + console.log(`[supervisor] could not re-read pty.toml for ${name}: ${err.message}, using stored metadata`); 336 + } 337 + } 338 + 339 + // Clean up the dead session 340 + cleanupAll(name); 341 + 342 + // Respawn 343 + await spawnDaemon({ name, command, args, displayCommand, cwd, tags }); 344 + 345 + tracked.restartCount++; 346 + tracked.lastRestartAt = Date.now(); 347 + tracked.nextBackoffMs = Math.min(tracked.nextBackoffMs * BACKOFF_MULTIPLIER, MAX_BACKOFF_MS); 348 + 349 + console.log(`[supervisor] restarted ${name} (attempt ${tracked.restartCount}/${MAX_RESTARTS})`); 350 + 351 + this.emitEvent(EventType.SESSION_RESTART, { 352 + session: name, 353 + restartCount: tracked.restartCount, 354 + backoffMs: tracked.nextBackoffMs, 355 + }); 356 + 357 + this.persistState(); 358 + } 359 + 360 + private markFailed(name: string): void { 361 + const tracked = this.sessions.get(name); 362 + if (!tracked) return; 363 + 364 + tracked.failed = true; 365 + if (tracked.pendingTimer) { 366 + clearTimeout(tracked.pendingTimer); 367 + tracked.pendingTimer = null; 368 + } 369 + 370 + console.log(`[supervisor] ${name} marked as FAILED (${tracked.restartCount} restarts in window)`); 371 + 372 + // Set failed tag on the session 373 + try { 374 + updateTags(name, { "supervisor.status": "failed" }, []); 375 + } catch { 376 + // Metadata may have been cleaned up 377 + } 378 + 379 + this.emitEvent(EventType.SESSION_FAILED, { 380 + session: name, 381 + restartCount: tracked.restartCount, 382 + reason: `exceeded ${MAX_RESTARTS} restarts in ${RESTART_WINDOW_MS / 1000}s`, 383 + }); 384 + 385 + this.persistState(); 386 + } 387 + 388 + private emitEvent(type: string, fields?: Record<string, unknown>): void { 389 + this.eventWriter.append({ 390 + session: this.supervisorName, 391 + type: type as EventRecord["type"], 392 + ts: new Date().toISOString(), 393 + ...fields, 394 + } as EventRecord); 395 + } 396 + 397 + private startWatching(): void { 398 + const sessionDir = getSessionDir(); 399 + try { 400 + this.dirWatcher = fs.watch(sessionDir, (eventType, filename) => { 401 + if (this.stopping) return; 402 + if (!filename || !filename.endsWith(".json")) return; 403 + const name = filename.replace(/\.json$/, ""); 404 + if (name === this.supervisorName) return; 405 + // Debounce: defer evaluation to next tick to let writes complete 406 + setImmediate(() => this.evaluateSession(name)); 407 + }); 408 + } catch (err) { 409 + console.error(`[supervisor] failed to watch session directory: ${err}`); 410 + } 411 + } 412 + 413 + private persistState(): void { 414 + const state: PersistedState = { 415 + sessions: {}, 416 + savedAt: new Date().toISOString(), 417 + }; 418 + for (const [name, tracked] of this.sessions) { 419 + if (tracked.strategy !== "permanent") continue; 420 + state.sessions[name] = { 421 + restartCount: tracked.restartCount, 422 + restartWindowStart: tracked.restartWindowStart, 423 + lastRestartAt: tracked.lastRestartAt, 424 + nextBackoffMs: tracked.nextBackoffMs, 425 + failed: tracked.failed, 426 + }; 427 + } 428 + const filePath = path.join(getSupervisorDir(), "state.json"); 429 + const tmp = filePath + ".tmp"; 430 + try { 431 + fs.writeFileSync(tmp, JSON.stringify(state, null, 2)); 432 + fs.renameSync(tmp, filePath); 433 + } catch { 434 + // Non-fatal 435 + } 436 + } 437 + 438 + private loadState(): void { 439 + const filePath = path.join(getSupervisorDir(), "state.json"); 440 + try { 441 + const content = fs.readFileSync(filePath, "utf-8"); 442 + const state: PersistedState = JSON.parse(content); 443 + for (const [name, saved] of Object.entries(state.sessions)) { 444 + this.sessions.set(name, { 445 + name, 446 + strategy: "permanent", 447 + ...saved, 448 + pendingTimer: null, 449 + }); 450 + } 451 + console.log(`[supervisor] loaded state: ${Object.keys(state.sessions).length} tracked sessions`); 452 + } catch { 453 + // No state file or invalid — start fresh 454 + } 455 + } 456 + }
+24 -4
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, ...(s.metadata.args ?? [])].join(" ") 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, ...(s.metadata.args ?? [])].join(" ") 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))})` : ""; ··· 149 149 ? cwdStr 150 150 : [cwdStr, exitStr].filter(Boolean).join(" "); 151 151 152 - const line = `${sel}${icon} ${s.name} ${pathStr} ${cmd}`; 153 - return [text(line, selected ? "accent" : "primary", { bold: selected, truncate: true })]; 152 + const supStatus = s.metadata?.tags?.["supervisor.status"]; 153 + const strategy = s.metadata?.tags?.strategy; 154 + const marker = supStatus === "failed" ? " [failed]" : strategy === "permanent" ? " [permanent]" : strategy === "temporary" ? " [temporary]" : ""; 155 + const markerColor: "error" | "warn" | "muted" = supStatus === "failed" ? "error" : strategy ? "warn" : "muted"; 156 + const iconColor = selected ? "accent" : s.status === "running" ? "ok" : "muted"; 157 + 158 + const parts: string[] = [ 159 + `${sel}${icon} `, 160 + ]; 161 + // Build as a single string but use dim for path/command 162 + const nameStr = `${sel}${icon} ${s.name}${marker}`; 163 + const detailStr = ` ${pathStr} ${cmd}`; 164 + const line = nameStr + detailStr; 165 + 166 + if (selected) { 167 + return [text(line, "accent", { bold: true, truncate: true })]; 168 + } 169 + // Use two text nodes: bold name + dim details 170 + return [ 171 + text(nameStr, s.status === "running" ? "primary" : "muted", { bold: true }), 172 + text(detailStr, "muted", { dim: true, truncate: true }), 173 + ]; 154 174 } 155 175 156 176 const listScreen = screen({
+147
tests/peek-wait.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-peekwait-")); 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 `pw${++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 + ): Promise<number> { 38 + const config = JSON.stringify({ 39 + name, command, args, displayCommand: command, 40 + cwd: os.tmpdir(), rows: 24, cols: 80, 41 + }); 42 + const child = spawn(nodeBin, [serverModule], { 43 + detached: true, 44 + stdio: ["ignore", "ignore", "pipe"], 45 + env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, 46 + }); 47 + let stderr = ""; 48 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 49 + let exitCode: number | null = null; 50 + child.on("exit", (code) => { exitCode = code; }); 51 + (child.stderr as any)?.unref?.(); 52 + child.unref(); 53 + 54 + const socketPath = path.join(sessionDir, `${name}.sock`); 55 + const start = Date.now(); 56 + while (Date.now() - start < 5000) { 57 + if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); 58 + try { 59 + fs.statSync(socketPath); 60 + await new Promise((r) => setTimeout(r, 100)); 61 + bgPids.push(child.pid!); 62 + return child.pid!; 63 + } catch {} 64 + await new Promise((r) => setTimeout(r, 50)); 65 + } 66 + throw new Error("Timeout waiting for daemon"); 67 + } 68 + 69 + function runCli(sessionDir: string, ...args: string[]) { 70 + return spawnSync(nodeBin, [cliPath, ...args], { 71 + env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 72 + encoding: "utf-8", 73 + timeout: 15000, 74 + }); 75 + } 76 + 77 + afterEach(() => { 78 + for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 79 + bgPids = []; 80 + for (const dir of sessionDirs) { 81 + try { 82 + for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 83 + } catch {} 84 + } 85 + sessionDirs = []; 86 + }); 87 + 88 + describe("pty peek --full", () => { 89 + it("shows full scrollback", async () => { 90 + const dir = makeSessionDir(); 91 + const name = uniqueName(); 92 + await startDaemon(dir, name, "sh", ["-c", "for i in $(seq 1 100); do echo line$i; done; exec cat"]); 93 + await new Promise((r) => setTimeout(r, 500)); 94 + 95 + const normal = runCli(dir, "peek", "--plain", name); 96 + const full = runCli(dir, "peek", "--plain", "--full", name); 97 + 98 + const normalLines = normal.stdout.trim().split("\n").length; 99 + const fullLines = full.stdout.trim().split("\n").length; 100 + 101 + expect(fullLines).toBeGreaterThan(normalLines); 102 + expect(fullLines).toBeGreaterThanOrEqual(100); 103 + expect(full.stdout).toContain("line1"); 104 + expect(full.stdout).toContain("line100"); 105 + }, 15000); 106 + }); 107 + 108 + describe("pty peek --wait", () => { 109 + it("waits until text appears", async () => { 110 + const dir = makeSessionDir(); 111 + const name = uniqueName(); 112 + // Print "READY" after a short delay 113 + await startDaemon(dir, name, "sh", ["-c", "sleep 0.5; echo READY; exec cat"]); 114 + 115 + const result = runCli(dir, "peek", "--wait", "READY", "-t", "5", "--plain", name); 116 + 117 + expect(result.status).toBe(0); 118 + expect(result.stdout).toContain("READY"); 119 + }, 15000); 120 + 121 + it("times out when text never appears", async () => { 122 + const dir = makeSessionDir(); 123 + const name = uniqueName(); 124 + await startDaemon(dir, name, "cat"); 125 + 126 + const result = runCli(dir, "peek", "--wait", "NEVER", "-t", "1", "--plain", name); 127 + 128 + expect(result.status).toBe(1); 129 + expect(result.stderr).toContain("Timed out"); 130 + expect(result.stderr).toContain("NEVER"); 131 + }, 15000); 132 + 133 + it("returns immediately if text is already on screen", async () => { 134 + const dir = makeSessionDir(); 135 + const name = uniqueName(); 136 + await startDaemon(dir, name, "sh", ["-c", "echo ALREADY; exec cat"]); 137 + await new Promise((r) => setTimeout(r, 300)); 138 + 139 + const start = Date.now(); 140 + const result = runCli(dir, "peek", "--wait", "ALREADY", "-t", "5", "--plain", name); 141 + const elapsed = Date.now() - start; 142 + 143 + expect(result.status).toBe(0); 144 + expect(result.stdout).toContain("ALREADY"); 145 + expect(elapsed).toBeLessThan(2000); 146 + }, 15000); 147 + });
+20 -1
tests/shells.test.ts
··· 1 - import { describe, it, expect } from "vitest"; 1 + import { describe, it, expect, afterAll } from "vitest"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 2 4 import { Session } from "../src/testing/index.ts"; 3 5 4 6 // Shell integration tests — verify that common shells start up, ··· 7 9 // bash and zsh use Session.spawn() (direct PTY, no server). 8 10 // fish uses Session.server() (PtyServer) to exercise the DA1 response 9 11 // handler — fish 4.x sends DA1 at startup and blocks 10s without it. 12 + // 13 + // Set PTY_SESSION_DIR to an isolated temp directory so PtyServer 14 + // doesn't pollute real sessions. Vitest runs each file in its own 15 + // worker, so this doesn't affect other test files. 16 + 17 + const testSessionDir = fs.mkdtempSync(os.tmpdir() + "/pty-shells-"); 18 + process.env.PTY_SESSION_DIR = testSessionDir; 19 + 20 + afterAll(() => { 21 + // Allow async exit metadata writes to complete before cleanup 22 + return new Promise((resolve) => { 23 + setTimeout(() => { 24 + try { fs.rmSync(testSessionDir, { recursive: true, force: true }); } catch {} 25 + resolve(undefined); 26 + }, 500); 27 + }); 28 + }); 10 29 11 30 describe("shell integration", () => { 12 31 describe("bash", () => {
+270
tests/supervisor.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 + const supervisorModule = path.join(__dirname, "..", "dist", "supervisor.js"); 13 + 14 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-sup-")); 15 + afterAll(() => { 16 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 17 + }); 18 + 19 + let bgPids: number[] = []; 20 + let sessionDirs: string[] = []; 21 + 22 + function makeSessionDir(): string { 23 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 24 + sessionDirs.push(dir); 25 + return dir; 26 + } 27 + 28 + let nameCounter = 0; 29 + function uniqueName(): string { 30 + return `sup${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; 31 + } 32 + 33 + async function startDaemon( 34 + sessionDir: string, 35 + name: string, 36 + command: string, 37 + args: string[] = [], 38 + tags?: Record<string, string>, 39 + ): Promise<number> { 40 + const config = JSON.stringify({ 41 + name, command, args, displayCommand: command, 42 + cwd: os.tmpdir(), rows: 24, cols: 80, tags, 43 + }); 44 + const child = spawn(nodeBin, [serverModule], { 45 + detached: true, 46 + stdio: ["ignore", "ignore", "pipe"], 47 + env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, 48 + }); 49 + let stderr = ""; 50 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 51 + let exitCode: number | null = null; 52 + child.on("exit", (code) => { exitCode = code; }); 53 + (child.stderr as any)?.unref?.(); 54 + child.unref(); 55 + 56 + const socketPath = path.join(sessionDir, `${name}.sock`); 57 + const start = Date.now(); 58 + while (Date.now() - start < 5000) { 59 + if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); 60 + try { 61 + fs.statSync(socketPath); 62 + await new Promise((r) => setTimeout(r, 100)); 63 + bgPids.push(child.pid!); 64 + return child.pid!; 65 + } catch {} 66 + await new Promise((r) => setTimeout(r, 50)); 67 + } 68 + throw new Error("Timeout waiting for daemon"); 69 + } 70 + 71 + function startSupervisor(sessionDir: string): number { 72 + const child = spawn(nodeBin, [cliPath, "supervisor", "start"], { 73 + detached: true, 74 + stdio: ["ignore", "ignore", "ignore"], 75 + env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 76 + }); 77 + child.unref(); 78 + bgPids.push(child.pid!); 79 + return child.pid!; 80 + } 81 + 82 + function waitForFile(filePath: string, timeoutMs = 5000): Promise<void> { 83 + const start = Date.now(); 84 + return new Promise((resolve, reject) => { 85 + function check() { 86 + if (Date.now() - start > timeoutMs) { 87 + reject(new Error(`Timeout waiting for ${filePath}`)); 88 + return; 89 + } 90 + try { 91 + fs.statSync(filePath); 92 + resolve(); 93 + return; 94 + } catch {} 95 + setTimeout(check, 100); 96 + } 97 + check(); 98 + }); 99 + } 100 + 101 + function readMeta(sessionDir: string, name: string): any { 102 + try { 103 + return JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf-8")); 104 + } catch { return null; } 105 + } 106 + 107 + afterEach(() => { 108 + for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 109 + bgPids = []; 110 + // Give supervisor time to release lock 111 + const start = Date.now(); 112 + while (Date.now() - start < 200) {} 113 + for (const dir of sessionDirs) { 114 + try { 115 + for (const e of fs.readdirSync(dir)) { 116 + try { fs.unlinkSync(path.join(dir, e)); } catch {} 117 + } 118 + } catch {} 119 + } 120 + sessionDirs = []; 121 + }); 122 + 123 + describe("supervisor", () => { 124 + it("restarts a permanent session that exits", async () => { 125 + const dir = makeSessionDir(); 126 + process.env.PTY_SESSION_DIR = dir; 127 + 128 + // Start a session that exits after 0.5s 129 + await startDaemon(dir, "restartable", "sh", ["-c", "sleep 0.5"], { strategy: "permanent" }); 130 + 131 + // Start the supervisor 132 + startSupervisor(dir); 133 + await new Promise((r) => setTimeout(r, 300)); // let supervisor start 134 + 135 + // Wait for session to exit + supervisor scan + backoff + restart 136 + // Session exits after 0.5s, supervisor scans every 30s but also uses fs.watch, 137 + // initial backoff is 1s, then spawnDaemon takes a moment 138 + const sockPath = path.join(dir, "restartable.sock"); 139 + const start = Date.now(); 140 + let restarted = false; 141 + while (Date.now() - start < 10000) { 142 + await new Promise((r) => setTimeout(r, 500)); 143 + // Check if a new socket appeared (the old one gets cleaned up by cleanupAll) 144 + const meta = readMeta(dir, "restartable"); 145 + // After restart, metadata should have no exitedAt (fresh session) 146 + if (meta && !meta.exitedAt) { 147 + try { fs.statSync(sockPath); restarted = true; break; } catch {} 148 + } 149 + } 150 + 151 + expect(restarted).toBe(true); 152 + }, 20000); 153 + 154 + it("cleans up temporary sessions on exit", async () => { 155 + const dir = makeSessionDir(); 156 + process.env.PTY_SESSION_DIR = dir; 157 + 158 + // Start a temporary session that exits immediately 159 + await startDaemon(dir, "tempjob", "true", [], { strategy: "temporary" }); 160 + 161 + // Start the supervisor 162 + startSupervisor(dir); 163 + await new Promise((r) => setTimeout(r, 300)); 164 + 165 + // Wait for exit + cleanup 166 + await new Promise((r) => setTimeout(r, 2000)); 167 + 168 + // Metadata should be gone 169 + const meta = readMeta(dir, "tempjob"); 170 + expect(meta).toBeNull(); 171 + }, 15000); 172 + 173 + it("does not restart sessions without strategy tag", async () => { 174 + const dir = makeSessionDir(); 175 + process.env.PTY_SESSION_DIR = dir; 176 + 177 + // Start a session without strategy that exits 178 + await startDaemon(dir, "nosupervise", "sh", ["-c", "sleep 0.3"]); 179 + 180 + startSupervisor(dir); 181 + await new Promise((r) => setTimeout(r, 300)); 182 + 183 + // Wait for exit + potential restart window 184 + await new Promise((r) => setTimeout(r, 3000)); 185 + 186 + // Should NOT have restarted — no socket 187 + const sockPath = path.join(dir, "nosupervise.sock"); 188 + let exists = false; 189 + try { fs.statSync(sockPath); exists = true; } catch {} 190 + expect(exists).toBe(false); 191 + }, 15000); 192 + 193 + it("pty tag can add strategy to a running session", async () => { 194 + const dir = makeSessionDir(); 195 + process.env.PTY_SESSION_DIR = dir; 196 + 197 + await startDaemon(dir, "latejoin", "cat"); 198 + 199 + const result = spawnSync(nodeBin, [cliPath, "tag", "latejoin", "strategy=permanent"], { 200 + env: { ...process.env, PTY_SESSION_DIR: dir }, 201 + encoding: "utf-8", 202 + }); 203 + expect(result.status).toBe(0); 204 + 205 + const meta = readMeta(dir, "latejoin"); 206 + expect(meta.tags.strategy).toBe("permanent"); 207 + }, 15000); 208 + 209 + it("pty supervisor forget removes strategy tag", async () => { 210 + const dir = makeSessionDir(); 211 + process.env.PTY_SESSION_DIR = dir; 212 + 213 + await startDaemon(dir, "forgetme", "cat", [], { strategy: "permanent" }); 214 + 215 + const result = spawnSync(nodeBin, [cliPath, "supervisor", "forget", "forgetme"], { 216 + env: { ...process.env, PTY_SESSION_DIR: dir }, 217 + encoding: "utf-8", 218 + }); 219 + expect(result.status).toBe(0); 220 + expect(result.stdout).toContain("Removed supervision"); 221 + 222 + const meta = readMeta(dir, "forgetme"); 223 + expect(meta.tags?.strategy).toBeUndefined(); 224 + }, 15000); 225 + 226 + it("pty down stops supervised sessions and removes strategy tag", async () => { 227 + const projDir = fs.mkdtempSync(path.join(testRoot, "proj-")); 228 + const dir = makeSessionDir(); 229 + fs.writeFileSync(path.join(projDir, "pty.toml"), ` 230 + [sessions.guarded] 231 + command = "cat" 232 + tags = { strategy = "permanent" } 233 + `); 234 + 235 + // Start via pty up 236 + spawnSync(nodeBin, [cliPath, "up", projDir], { 237 + env: { ...process.env, PTY_SESSION_DIR: dir }, 238 + encoding: "utf-8", 239 + }); 240 + 241 + // Stop it 242 + const result = spawnSync(nodeBin, [cliPath, "down", projDir], { 243 + env: { ...process.env, PTY_SESSION_DIR: dir }, 244 + encoding: "utf-8", 245 + }); 246 + 247 + expect(result.status).toBe(0); 248 + expect(result.stdout).toContain("stopped"); 249 + expect(result.stdout).toContain("removed from supervision"); 250 + 251 + // Strategy tag should be gone 252 + const meta = readMeta(dir, "guarded"); 253 + expect(meta?.tags?.strategy).toBeUndefined(); 254 + }, 15000); 255 + 256 + it("pty list shows strategy markers", async () => { 257 + const dir = makeSessionDir(); 258 + process.env.PTY_SESSION_DIR = dir; 259 + 260 + await startDaemon(dir, "marked", "cat", [], { strategy: "permanent" }); 261 + 262 + const result = spawnSync(nodeBin, [cliPath, "list"], { 263 + env: { ...process.env, PTY_SESSION_DIR: dir }, 264 + encoding: "utf-8", 265 + }); 266 + 267 + expect(result.stdout).toContain("marked"); 268 + expect(result.stdout).toContain("[permanent]"); 269 + }, 15000); 270 + });
+181
tests/tag-mutate.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-tagmut-")); 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 `tm${++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: 10000, 75 + }); 76 + } 77 + 78 + function readMeta(sessionDir: string, name: string) { 79 + return JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf-8")); 80 + } 81 + 82 + afterEach(() => { 83 + for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 84 + bgPids = []; 85 + for (const dir of sessionDirs) { 86 + try { 87 + for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 88 + } catch {} 89 + } 90 + sessionDirs = []; 91 + }); 92 + 93 + describe("pty tag (mutable tags)", () => { 94 + it("sets tags on a running session", async () => { 95 + const dir = makeSessionDir(); 96 + const name = uniqueName(); 97 + await startDaemon(dir, name, "cat"); 98 + 99 + const result = runCli(dir, "tag", name, "role=server", "env=dev"); 100 + expect(result.status).toBe(0); 101 + expect(result.stdout).toContain("role=server"); 102 + expect(result.stdout).toContain("env=dev"); 103 + 104 + const meta = readMeta(dir, name); 105 + expect(meta.tags).toEqual({ role: "server", env: "dev" }); 106 + }, 15000); 107 + 108 + it("updates existing tags", async () => { 109 + const dir = makeSessionDir(); 110 + const name = uniqueName(); 111 + await startDaemon(dir, name, "cat", [], { role: "old" }); 112 + 113 + runCli(dir, "tag", name, "role=new"); 114 + 115 + const meta = readMeta(dir, name); 116 + expect(meta.tags.role).toBe("new"); 117 + }, 15000); 118 + 119 + it("removes tags with --rm", async () => { 120 + const dir = makeSessionDir(); 121 + const name = uniqueName(); 122 + await startDaemon(dir, name, "cat", [], { role: "server", env: "dev" }); 123 + 124 + runCli(dir, "tag", name, "--rm", "env"); 125 + 126 + const meta = readMeta(dir, name); 127 + expect(meta.tags).toEqual({ role: "server" }); 128 + expect(meta.tags.env).toBeUndefined(); 129 + }, 15000); 130 + 131 + it("removing all tags clears the field", async () => { 132 + const dir = makeSessionDir(); 133 + const name = uniqueName(); 134 + await startDaemon(dir, name, "cat", [], { only: "tag" }); 135 + 136 + runCli(dir, "tag", name, "--rm", "only"); 137 + 138 + const meta = readMeta(dir, name); 139 + expect(meta.tags).toBeUndefined(); 140 + }, 15000); 141 + 142 + it("shows current tags with no arguments", async () => { 143 + const dir = makeSessionDir(); 144 + const name = uniqueName(); 145 + await startDaemon(dir, name, "cat", [], { role: "server" }); 146 + 147 + const result = runCli(dir, "tag", name); 148 + expect(result.status).toBe(0); 149 + expect(result.stdout).toContain("role=server"); 150 + }, 15000); 151 + 152 + it("shows 'no tags' when empty", async () => { 153 + const dir = makeSessionDir(); 154 + const name = uniqueName(); 155 + await startDaemon(dir, name, "cat"); 156 + 157 + const result = runCli(dir, "tag", name); 158 + expect(result.stdout).toContain("No tags"); 159 + }, 15000); 160 + 161 + it("works on exited sessions", async () => { 162 + const dir = makeSessionDir(); 163 + const name = uniqueName(); 164 + await startDaemon(dir, name, "true"); // exits immediately 165 + await new Promise((r) => setTimeout(r, 1000)); 166 + 167 + const result = runCli(dir, "tag", name, "strategy=permanent"); 168 + expect(result.status).toBe(0); 169 + 170 + const meta = readMeta(dir, name); 171 + expect(meta.tags).toEqual({ strategy: "permanent" }); 172 + }, 15000); 173 + 174 + it("errors on nonexistent session", () => { 175 + const dir = makeSessionDir(); 176 + 177 + const result = runCli(dir, "tag", "nonexistent", "foo=bar"); 178 + expect(result.status).not.toBe(0); 179 + expect(result.stderr).toContain("not found"); 180 + }, 15000); 181 + });
+89 -1
tests/up-down.test.ts
··· 137 137 expect(result.stdout).toContain("All sessions already running"); 138 138 }, 15000); 139 139 140 + it("syncs tags to already-running sessions on pty up", () => { 141 + const projDir = makeProjectDir(); 142 + const sessDir = makeSessionDir(); 143 + 144 + // Start with no tags 145 + writePtyToml(projDir, ` 146 + [sessions.syncme] 147 + command = "cat" 148 + `); 149 + runCli(sessDir, "up", projDir); 150 + 151 + // Update toml to add tags 152 + writePtyToml(projDir, ` 153 + [sessions.syncme] 154 + command = "cat" 155 + tags = { strategy = "permanent", role = "server" } 156 + `); 157 + 158 + const result = runCli(sessDir, "up", projDir); 159 + expect(result.stdout).toContain("updated tags: strategy=permanent, role=server"); 160 + 161 + // Verify tags were applied 162 + const sessions = listJson(sessDir); 163 + const session = sessions.find((s: any) => s.name === "syncme"); 164 + expect(session.tags.strategy).toBe("permanent"); 165 + expect(session.tags.role).toBe("server"); 166 + expect(session.tags.ptyfile).toBeDefined(); 167 + }, 15000); 168 + 169 + it("does not remove manually-added tags on pty up", () => { 170 + const projDir = makeProjectDir(); 171 + const sessDir = makeSessionDir(); 172 + writePtyToml(projDir, ` 173 + [sessions.manual] 174 + command = "cat" 175 + tags = { role = "server" } 176 + `); 177 + 178 + runCli(sessDir, "up", projDir); 179 + 180 + // Manually add an extra tag 181 + runCli(sessDir, "tag", "manual", "custom=yes"); 182 + 183 + // Run pty up again — should NOT remove the custom tag 184 + runCli(sessDir, "up", projDir); 185 + 186 + const sessions = listJson(sessDir); 187 + const session = sessions.find((s: any) => s.name === "manual"); 188 + expect(session.tags.role).toBe("server"); 189 + expect(session.tags.custom).toBe("yes"); 190 + }, 15000); 191 + 192 + it("no output for already-running sessions with matching tags", () => { 193 + const projDir = makeProjectDir(); 194 + const sessDir = makeSessionDir(); 195 + writePtyToml(projDir, ` 196 + [sessions.unchanged] 197 + command = "cat" 198 + tags = { role = "server" } 199 + `); 200 + 201 + runCli(sessDir, "up", projDir); 202 + 203 + // Run again — tags match, no update message 204 + const result = runCli(sessDir, "up", projDir); 205 + expect(result.stdout).toContain("unchanged (already running)"); 206 + expect(result.stdout).not.toContain("updated tags"); 207 + }, 15000); 208 + 140 209 it("propagates tags from pty.toml", () => { 141 210 const projDir = makeProjectDir(); 142 211 const sessDir = makeSessionDir(); ··· 151 220 const sessions = listJson(sessDir); 152 221 const session = sessions.find((s: any) => s.name === "tagged"); 153 222 expect(session).toBeDefined(); 154 - expect(session.tags).toEqual({ role: "server", env: "dev" }); 223 + expect(session.tags.role).toBe("server"); 224 + expect(session.tags.env).toBe("dev"); 225 + expect(session.tags.ptyfile).toBeDefined(); 155 226 }, 15000); 156 227 157 228 it("sets cwd to the project directory", () => { ··· 217 288 const running = sessions.filter((s: any) => s.status === "running"); 218 289 expect(running).toHaveLength(1); 219 290 expect(running[0].name).toBe("myapp-web"); 291 + }, 15000); 292 + 293 + it("sets ptyfile tags on created sessions", () => { 294 + const projDir = makeProjectDir(); 295 + const sessDir = makeSessionDir(); 296 + writePtyToml(projDir, ` 297 + [sessions.tracked] 298 + command = "cat" 299 + `); 300 + 301 + runCli(sessDir, "up", projDir); 302 + 303 + const sessions = listJson(sessDir); 304 + const session = sessions.find((s: any) => s.name === "tracked"); 305 + expect(session).toBeDefined(); 306 + expect(session.tags.ptyfile).toBe(projDir + "/pty.toml"); 307 + expect(session.tags["ptyfile.session"]).toBe("tracked"); 220 308 }, 15000); 221 309 222 310 it("errors on unknown session name", () => {