This repository has no description
0

Configure Feed

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

Fix peek on exited sessions, multiple --wait, and TUI exit race

peek and peek --wait now read saved output from exited sessions
(lastLines increased to 200). --wait accepts multiple patterns
(any match). saveExitMetadata runs twice: immediately in onExit
(for status display) and again in close() (for complete output).
Fix TUI exit race by delaying session list refresh 200ms. Fix
SKILL.md examples to use multiple --wait instead of regex.

Nathan Herald (Apr 14, 2026, 2:26 PM +0200) f0d1aca7 2cc6a536

+65 -19
+2 -2
docs/SKILL.md
··· 136 136 137 137 ```bash 138 138 pty run -d --name tests --tag owner=agent -- npm test 139 - pty peek --wait "passed\|failed" --plain tests -t 120 139 + pty peek --wait "passed" --wait "failed" --plain tests -t 120 140 140 pty peek --full --plain tests 141 141 pty kill tests 142 142 ``` ··· 145 145 146 146 ```bash 147 147 pty run -d --name build --tag owner=agent -- npm run build 148 - pty peek --wait "error\|successfully" --plain build -t 60 148 + pty peek --wait "error" --wait "successfully" --plain build -t 60 149 149 pty peek --full --plain build 150 150 pty kill build 151 151 ```
+43 -11
src/cli.ts
··· 257 257 let follow = false; 258 258 let plain = false; 259 259 let full = false; 260 - let waitPattern: string | null = null; 260 + const waitPatterns: string[] = []; 261 261 let timeoutSec = 0; 262 262 let pi = 1; 263 263 while (pi < args.length && args[pi].startsWith("-")) { 264 264 if (args[pi] === "-f" || args[pi] === "--follow") { follow = true; pi++; } 265 265 else if (args[pi] === "--plain") { plain = true; pi++; } 266 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; } 267 + else if (args[pi] === "--wait" && pi + 1 < args.length) { waitPatterns.push(args[pi + 1]); pi += 2; } 268 268 else if ((args[pi] === "-t" || args[pi] === "--timeout") && pi + 1 < args.length) { timeoutSec = parseFloat(args[pi + 1]); pi += 2; } 269 269 else break; 270 270 } ··· 279 279 console.error(e.message); 280 280 process.exit(1); 281 281 } 282 - if (waitPattern) { 283 - await cmdPeekWait(peekName, waitPattern, timeoutSec, plain); 282 + if (waitPatterns.length > 0) { 283 + await cmdPeekWait(peekName, waitPatterns, timeoutSec, plain); 284 284 } else { 285 285 cmdPeek(peekName, follow, plain, full); 286 286 } ··· 813 813 }); 814 814 } 815 815 816 - async function cmdPeekWait(name: string, pattern: string, timeoutSec: number, plain: boolean): Promise<void> { 816 + async function cmdPeekWait(name: string, patterns: string[], timeoutSec: number, plain: boolean): Promise<void> { 817 817 const { peekScreen } = await import("./connection.ts"); 818 818 const start = Date.now(); 819 819 const timeoutMs = timeoutSec > 0 ? timeoutSec * 1000 : 0; 820 + const matchesAny = (text: string) => patterns.some((p) => text.includes(p)); 821 + const patternDesc = patterns.length === 1 ? `"${patterns[0]}"` : patterns.map((p) => `"${p}"`).join(" or "); 820 822 821 823 while (true) { 822 824 if (timeoutMs > 0 && Date.now() - start > timeoutMs) { 823 - console.error(`Timed out after ${timeoutSec}s waiting for "${pattern}".`); 825 + console.error(`Timed out after ${timeoutSec}s waiting for ${patternDesc}.`); 824 826 process.exit(1); 825 827 } 826 828 829 + // Try live session first 827 830 try { 828 831 const screen = await peekScreen({ name, plain: true }); 829 - if (screen.includes(pattern)) { 832 + if (matchesAny(screen)) { 830 833 if (plain) { 831 834 process.stdout.write(screen + "\n"); 832 835 } else { ··· 835 838 } 836 839 return; 837 840 } 838 - } catch (err: any) { 839 - console.error(err.message); 840 - process.exit(1); 841 + } catch { 842 + // Session might have exited — check metadata for lastLines 843 + const meta = readMetadata(name); 844 + if (meta?.exitedAt && meta.lastLines) { 845 + const lastOutput = meta.lastLines.join("\n"); 846 + if (matchesAny(lastOutput)) { 847 + process.stdout.write(lastOutput + "\n"); 848 + return; 849 + } 850 + // Session exited but pattern not found in last lines 851 + console.error(`Session "${name}" exited (code ${meta.exitCode ?? "?"}) without matching ${patternDesc}.`); 852 + if (meta.lastLines.length > 0) { 853 + console.error("Last output:"); 854 + for (const line of meta.lastLines) { 855 + console.error(` ${line}`); 856 + } 857 + } 858 + process.exit(1); 859 + } 860 + // No exitedAt — might be a transient connection error, retry 841 861 } 842 862 843 863 await new Promise((r) => setTimeout(r, 200)); 844 864 } 845 865 } 846 866 847 - function cmdPeek(name: string, follow: boolean, plain: boolean, full = false): void { 867 + async function cmdPeek(name: string, follow: boolean, plain: boolean, full = false): Promise<void> { 868 + // Check if session is exited — fall back to saved lastLines 869 + const session = await getSession(name); 870 + if (session?.status === "exited") { 871 + const meta = session.metadata; 872 + if (meta?.lastLines && meta.lastLines.length > 0) { 873 + process.stdout.write(meta.lastLines.join("\n") + "\n"); 874 + } else { 875 + console.error(`Session "${name}" has exited with no saved output.`); 876 + } 877 + return; 878 + } 879 + 848 880 peek({ 849 881 name, 850 882 follow,
+12 -1
src/server.ts
··· 50 50 onExit?: (code: number) => void; 51 51 } 52 52 53 - const LAST_LINES_COUNT = 20; 53 + const LAST_LINES_COUNT = 200; 54 54 55 55 export interface ProcessResources { 56 56 rssKb: number; // Resident set size in KB ··· 289 289 this.exited = true; 290 290 this.exitCode = exitCode; 291 291 this.broadcast(encodeExit(exitCode)); 292 + // Save exit status immediately so the session shows as "exited" 293 + // in pty list during the cleanup window. lastLines may be incomplete 294 + // here since PTY data could still be in-flight — close() will 295 + // update with the final output. 292 296 this.saveExitMetadata(exitCode); 293 297 options.onExit?.(exitCode); 294 298 }); ··· 624 628 625 629 /** Clean up resources. Does not call process.exit(). */ 626 630 close(): Promise<void> { 631 + // Update exit metadata with final output — by the time close() runs, 632 + // all PTY data has been delivered to the terminal buffer. This overwrites 633 + // the initial save from onExit which may have had incomplete lastLines. 634 + if (this.exited) { 635 + this.saveExitMetadata(this.exitCode); 636 + } 637 + 627 638 return new Promise((resolve) => { 628 639 for (const client of this.clients.values()) { 629 640 client.socket.destroy();
+2
src/tui/interactive.ts
··· 552 552 myApp?.resume(); 553 553 }, 554 554 onExit: async (_code) => { 555 + // Brief delay to let the daemon write exit metadata to disk 556 + await new Promise((r) => setTimeout(r, 200)); 555 557 const updated = await listSessions(); 556 558 batch(() => { 557 559 sessions.set(updated);
+2 -2
tests/integration.test.ts
··· 531 531 "echo 'line one'; echo 'line two'; echo 'line three'; sleep 0.2; exit 7", 532 532 ]); 533 533 534 - // Wait for the process to exit and metadata to be written 535 - await new Promise((r) => setTimeout(r, 500)); 534 + // Wait for the process to exit (200ms) + shutdown delay (500ms) + buffer 535 + await new Promise((r) => setTimeout(r, 1000)); 536 536 537 537 const meta = readMetadata(name); 538 538 expect(meta).not.toBeNull();
+4 -3
tests/tui.test.ts
··· 702 702 // is still alive (500ms cleanup delay) but metadata has exitedAt set 703 703 tui.sendKeys("\x04"); // Ctrl+D 704 704 705 - // Wait for TUI to return to the list 705 + // Wait for TUI to return to the list and show the session as exited. 706 + // There's a brief window after Ctrl+D where the daemon is still alive 707 + // but exitedAt has been written — the TUI needs to refresh to pick it up. 706 708 await tui.waitForText("Create new session...", 10000); 709 + await tui.waitForText("exited", 5000); 707 710 708 - // The session must show as exited, not running 709 711 const ss = tui.screenshot(); 710 - expect(ss.text).toContain("exited"); 711 712 expect(ss.text).toContain(name); 712 713 }, 713 714 20000