@yusukeshib/pi-babysit 0.6.4 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,7 +56,7 @@ programs** (installers, wizards, REPLs): type with `babysit_send`
56
56
 
57
57
  | Tool | What it does |
58
58
  | ---- | ------------ |
59
- | `babysit_run` | Run any command (`command`, optional `name`/`pty`/`timeout`/`idleTimeout`/`retryOnWorkerDeath`/`notificationGroup`). Set `foreground: true` for one process or subagent whose result is needed in the same tool call; use `returnPattern`/`returnLines`/`maxBytes` to keep noisy process output bounded. Or start a named background subagent (`profile: "subagent"`, `task`, optional `name`/`agent`/`model`/`tools`/`maxDepth` and budget fields), then always collect it with `babysit_wait`. `maxDepth` defaults to 1. Subagent `continueAfterStart: true` is accepted as a compatibility alias for this default background behavior. Quick commands return inline; longer process runs notify in the background |
59
+ | `babysit_run` | Run any command (`command`, optional `name`/`pty`/`timeout`/`idleTimeout`/`retryOnWorkerDeath`/`notificationGroup`/`lifecycle`). Set `foreground: true` for one process or subagent whose result is needed in the same tool call; use `returnPattern`/`returnLines`/`maxBytes` to keep noisy process output bounded. Foreground and non-interactive process waits default to lifecycle `attached`, which terminates the process tree if that owning tool call is interrupted; background starts default to `detached`. Or start a named background subagent (`profile: "subagent"`, `task`, optional `name`/`agent`/`model`/`tools`/`maxDepth` and budget fields), then always collect it with `babysit_wait`. `maxDepth` defaults to 1. Subagent `continueAfterStart: true` is accepted as a compatibility alias for this default background behavior. Quick commands return inline; longer process runs notify in the background |
60
60
  | `babysit_check` | Without an id, list sessions with state/kind filters. With an id, inspect bounded output, search with `pattern`, or capture a TUI with `screen: true`; `maxBytes` overrides the 4 KB default up to 24 KB |
61
61
  | `babysit_send` | Process: type `text` / press `keys` into the PTY. Subagent: steer mid-run, or send a follow-up task when confirmed settled (`mode: auto/steer/task`); explicit task mode rejects busy, parked, or unknown state |
62
62
  | `babysit_wait` | Block until done: process exit (or `expect: "regex"` readiness marker), subagent task completion. Multi-wait: up to 32 unique `ids` + `mode: "any"\|"all"` |
@@ -80,8 +80,14 @@ backgrounding (`… &`, `nohup`, `setsid`, `disown`). Set
80
80
 
81
81
  A widget below the editor separates session **kind** from task **state** at a glance:
82
82
  summary counts use `RUNNING` / `IDLE`, and every row is labeled
83
- `PROCESS RUNNING`, `AGENT RUNNING`, or `AGENT IDLE`. The displayed `age`
84
- is total session age, not time spent in the current state. Transcript details
83
+ `PROCESS RUNNING`, `AGENT RUNNING`, or `AGENT IDLE`. In fullscreen mode, click
84
+ any live row to toggle between its latest line and latest 20 available output
85
+ lines. Finished sessions are behind a clickable `FINISHED` row; browse all
86
+ retained sessions five at a time and click a row to expand it, including failed
87
+ processes and agents. A session with no available output shows an explicit
88
+ placeholder when expanded. Mouse clicks require fullscreen mode; `/babysit`
89
+ works in regular mode. The displayed `age` is total session age, not time spent
90
+ in the current state. Transcript details
85
91
  from tool results, snapshots, and completion notifications collapse or expand
86
92
  with Pi's tool-output toggle (`Ctrl+O` by default).
87
93
 
@@ -132,7 +138,20 @@ suite at the end. Background subagents must likewise be collected before the
132
138
  parent task finishes so their answer and nested usage are not lost.
133
139
  Set `PI_BABYSIT_ALLOW_BASH=1` only as an explicit emergency escape hatch.
134
140
 
135
- ## Pi shutdown and reload
141
+ ## Process ownership, interruption, shutdown, and reload
142
+
143
+ An inline process wait (`foreground: true`, or process execution in a non-UI
144
+ mode) defaults to `lifecycle: "attached"`. If its owning `babysit_run` tool call
145
+ is interrupted through Pi's AbortSignal, pi-babysit terminates the supervised
146
+ process tree through the babysit session API and verifies persisted terminal
147
+ state before returning. Set `lifecycle: "detached"` explicitly only when a
148
+ foreground wait may be interrupted but the process must continue. Interactive
149
+ background starts are detached; `lifecycle: "attached"` therefore requires
150
+ `foreground: true` in interactive mode.
151
+
152
+ An explicit `babysit_wait` is only a waiter, not the process owner. Interrupting
153
+ it never terminates the process; call `babysit_kill` when termination is wanted.
154
+ This cooperative policy does not claim to detect an abrupt host `SIGKILL`.
136
155
 
137
156
  A real Pi quit terminates every running process and subagent in the current Pi
138
157
  session namespace. The workers are killed concurrently, so shutdown time does
package/index.ts CHANGED
@@ -43,7 +43,7 @@ import type {
43
43
  ToolDefinition,
44
44
  } from "@earendil-works/pi-coding-agent";
45
45
  import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
46
- import { Box, Markdown, Text } from "@earendil-works/pi-tui";
46
+ import { Box, Markdown, Text, truncateToWidth } from "@earendil-works/pi-tui";
47
47
  import { Type, type TSchema } from "typebox";
48
48
  import { StringEnum } from "@earendil-works/pi-ai";
49
49
  import { type AgentConfig, type AgentScope, discoverAgents } from "./agents";
@@ -1449,36 +1449,63 @@ export function deliverProcessCompletionMessage(
1449
1449
  // parked-turn detection (shared rule with self-reap.ts)
1450
1450
  // ---------------------------------------------------------------------------
1451
1451
 
1452
- // A turn that ends right after `babysit_run { command }` only means "parked
1453
- // awaiting the process-exit notification" — pi resumes it on its own; that is
1454
- // NOT task completion. Such runs stamp NOTIFY_MARKER into their tool result,
1455
- // so the marker in the LAST message's toolResult identifies a parked turn.
1456
- // (A subagent-profile run does NOT carry the marker: ending a turn to "wait"
1457
- // for a subagent is a guidance violation, and treating it as completion keeps
1458
- // the parent from hanging forever.) `process` is the legacy pi-processes tool.
1459
- function isParkedMessages(
1460
- messages:
1461
- | {
1462
- role?: string;
1463
- toolName?: string;
1464
- content?: unknown;
1465
- details?: { kind?: string; status?: string };
1466
- }[]
1467
- | undefined,
1468
- ): boolean {
1452
+ // A turn that ends after `babysit_run { command }` can be parked awaiting an
1453
+ // automatic process-exit notification. A later terminal babysit_wait/kill for
1454
+ // that same id consumes the start, however, so it must not make a completed
1455
+ // subagent task look parked. (A subagent-profile run does not carry the marker.)
1456
+ // `process` is the legacy pi-processes tool and remains conservative because it
1457
+ // does not expose equivalent structured collection metadata.
1458
+ type ParkedMessage = {
1459
+ role?: string;
1460
+ toolName?: string;
1461
+ content?: unknown;
1462
+ details?: {
1463
+ id?: string;
1464
+ kind?: string;
1465
+ status?: string | { id?: string; state?: string };
1466
+ results?: Array<{ id?: string; kind?: string }>;
1467
+ first?: { id?: string; kind?: string };
1468
+ };
1469
+ };
1470
+
1471
+ function isParkedMessages(messages: ParkedMessage[] | undefined): boolean {
1469
1472
  if (!messages) return false;
1470
- // Models sometimes add a short assistant note after starting a process. Scan
1471
- // the run rather than requiring the marker-bearing tool result to be the last
1472
- // message, otherwise that harmless note turns a parked build into false task
1473
- // completion (and lets the self-reaper kill it).
1473
+ const terminalCounts = new Map<string, number>();
1474
+ const addTerminal = (id: string) => terminalCounts.set(id, (terminalCounts.get(id) ?? 0) + 1);
1475
+
1476
+ // Walk backwards so only a terminal collection that occurred after a start
1477
+ // can consume it. Counts preserve repeated starts that reuse the same id.
1474
1478
  for (let i = messages.length - 1; i >= 0; i--) {
1475
1479
  const message = messages[i];
1476
1480
  if (message?.role !== "toolResult") continue;
1481
+ if (message.toolName === "babysit_wait") {
1482
+ const status = message.details?.status;
1483
+ if (
1484
+ status &&
1485
+ typeof status === "object" &&
1486
+ typeof status.id === "string" &&
1487
+ typeof status.state === "string" &&
1488
+ status.state !== "running"
1489
+ ) {
1490
+ addTerminal(status.id);
1491
+ }
1492
+ for (const result of message.details?.results ?? []) {
1493
+ if (result.kind === "exited" && typeof result.id === "string") addTerminal(result.id);
1494
+ }
1495
+ const first = message.details?.first;
1496
+ if (first?.kind === "exited" && typeof first.id === "string") addTerminal(first.id);
1497
+ continue;
1498
+ }
1499
+ if (
1500
+ message.toolName === "babysit_kill" &&
1501
+ typeof message.details?.id === "string" &&
1502
+ ["exited", "killed", "dead"].includes(String(message.details.status))
1503
+ ) {
1504
+ addTerminal(message.details.id);
1505
+ continue;
1506
+ }
1477
1507
  if (message.toolName === "process") return true; // legacy pi-processes
1478
1508
  if (message.toolName !== "babysit_run") continue;
1479
- if (message.details?.kind === "process" && message.details.status === "started") {
1480
- return true;
1481
- }
1482
1509
  const text = Array.isArray(message.content)
1483
1510
  ? message.content
1484
1511
  .filter((part): part is { type: "text"; text: string } =>
@@ -1489,7 +1516,16 @@ function isParkedMessages(
1489
1516
  : typeof message.content === "string"
1490
1517
  ? message.content
1491
1518
  : "";
1492
- if (/^Process started \(id: [^)]+\)\. \[notify-on-exit\]\nLog: /.test(text)) return true;
1519
+ const textMatch = /^Process started \(id: ([^)]+)\)\. \[notify-on-exit\]\nLog: /.exec(text);
1520
+ const structuredStart =
1521
+ message.details?.kind === "process" && message.details.status === "started";
1522
+ if (!structuredStart && !textMatch) continue;
1523
+ const id = typeof message.details?.id === "string" ? message.details.id : textMatch?.[1];
1524
+ if (!id) return true;
1525
+ const count = terminalCounts.get(id) ?? 0;
1526
+ if (count === 0) return true;
1527
+ if (count === 1) terminalCounts.delete(id);
1528
+ else terminalCounts.set(id, count - 1);
1493
1529
  }
1494
1530
  return false;
1495
1531
  }
@@ -1764,16 +1800,7 @@ function parseEventLine(progress: Progress, raw: string): void {
1764
1800
  progress.lastEndWasProcessWait =
1765
1801
  typeof event.piBabysitParked === "boolean"
1766
1802
  ? event.piBabysitParked
1767
- : isParkedMessages(
1768
- event.messages as
1769
- | {
1770
- role?: string;
1771
- toolName?: string;
1772
- content?: unknown;
1773
- details?: { kind?: string; status?: string };
1774
- }[]
1775
- | undefined,
1776
- );
1803
+ : isParkedMessages(event.messages as ParkedMessage[] | undefined);
1777
1804
  break;
1778
1805
  }
1779
1806
  case "agent_settled":
@@ -2260,14 +2287,23 @@ async function spawnSubagent(
2260
2287
  }
2261
2288
 
2262
2289
  // ---------------------------------------------------------------------------
2263
- // widget (live counts)
2290
+ // widget (live counts and browsable finished sessions)
2264
2291
  // ---------------------------------------------------------------------------
2265
2292
 
2293
+ export function widgetSessionGroups(sessions: BsSession[], startedAt: (id: string) => number) {
2294
+ const active = sessions.filter((session) => session.state === "running");
2295
+ // Default view stays compact; the finished section pages over every retained session.
2296
+ const finished = sessions.filter((session) => session.state !== "running");
2297
+ const started = new Map(finished.map((session) => [session.id, startedAt(session.id)]));
2298
+ finished.sort((a, b) => (started.get(b.id) ?? 0) - (started.get(a.id) ?? 0) || b.id.localeCompare(a.id));
2299
+ return { active, finished };
2300
+ }
2301
+
2266
2302
  // A subagent whose task is done stays alive as an idle RPC worker (for
2267
2303
  // follow-ups), so "running" in babysit does NOT mean "working" — count
2268
2304
  // processes / busy subagents / idle subagents separately.
2269
2305
  export type WidgetSessionKind = "process" | "agent";
2270
- export type WidgetSessionState = "running" | "idle";
2306
+ export type WidgetSessionState = "running" | "idle" | "finished" | "failed";
2271
2307
 
2272
2308
  export function widgetSummaryText(procs: number, busy: number, idle: number): string {
2273
2309
  const sections: string[] = [];
@@ -2285,7 +2321,7 @@ export function widgetSessionHeader(
2285
2321
  state: WidgetSessionState,
2286
2322
  elapsed?: string,
2287
2323
  ): string {
2288
- const icon = state === "running" ? "▶" : "○";
2324
+ const icon = state === "running" ? "▶" : state === "failed" ? "✗" : "○";
2289
2325
  return ` ${icon} ${id} ${kind.toUpperCase()} ${state.toUpperCase()}${elapsed ? ` age ${elapsed}` : ""}`;
2290
2326
  }
2291
2327
 
@@ -2311,16 +2347,121 @@ function renderWidgetSessionHeader(
2311
2347
  elapsed: string,
2312
2348
  theme: Theme,
2313
2349
  ): string {
2314
- const running = state === "running";
2315
- const icon = theme.fg(running ? "success" : "muted", running ? "▶" : "○");
2350
+ const color = state === "running" ? "success" : state === "failed" ? "error" : "muted";
2351
+ const icon = theme.fg(color, state === "running" ? "▶" : state === "failed" ? "✗" : "○");
2316
2352
  const kindLabel = theme.fg(kind === "process" ? "accent" : "warning", theme.bold(kind.toUpperCase()));
2317
- const stateLabel = theme.fg(running ? "success" : "muted", theme.bold(state.toUpperCase()));
2353
+ const stateLabel = theme.fg(color, theme.bold(state.toUpperCase()));
2318
2354
  return ` ${icon} ${id} ${kindLabel} ${stateLabel}${elapsed ? theme.fg("dim", ` age ${elapsed}`) : ""}`;
2319
2355
  }
2320
2356
 
2321
- // How many trailing output lines to show per running session in the widget.
2322
- const WIDGET_TAIL_LINES = 1;
2357
+ // How many trailing output lines to show per session in the widget.
2358
+ const WIDGET_COLLAPSED_TAIL_LINES = 1;
2359
+ const WIDGET_EXPANDED_TAIL_LINES = 20;
2323
2360
  const WIDGET_TAIL_WIDTH = 100;
2361
+ const WIDGET_FINISHED_PAGE_SIZE = 5;
2362
+
2363
+ export interface WidgetFinishedState {
2364
+ open: boolean;
2365
+ page: number;
2366
+ }
2367
+
2368
+ export interface WidgetSessionDisplay {
2369
+ id: string;
2370
+ header: string;
2371
+ tail: string[];
2372
+ expandable: boolean;
2373
+ }
2374
+
2375
+ interface WidgetMouseEvent {
2376
+ type: string;
2377
+ button: string;
2378
+ y: number;
2379
+ }
2380
+
2381
+ /**
2382
+ * Build the live widget as a mouse-aware component without depending on newer
2383
+ * pi-tui mouse classes. Older hosts still render it normally; fullscreen hosts
2384
+ * dispatch component-local click coordinates to handleMouse.
2385
+ */
2386
+ export function createWidgetComponent(
2387
+ summaryLines: string[],
2388
+ sessions: WidgetSessionDisplay[],
2389
+ expandedSessionIds: Set<string>,
2390
+ finished?: { sessions: WidgetSessionDisplay[]; state: WidgetFinishedState },
2391
+ ) {
2392
+ type Target = { type: "session"; id: string } | { type: "finished" | "prev" | "next" };
2393
+ let targetByLine: Array<Target | undefined> = [];
2394
+
2395
+ return {
2396
+ render(width: number): string[] {
2397
+ const lines: string[] = [];
2398
+ targetByLine = [];
2399
+ const push = (text: string, target?: Target) => {
2400
+ lines.push(truncateToWidth(text, Math.max(0, width), ""));
2401
+ targetByLine.push(target);
2402
+ };
2403
+ const pushSession = (session: WidgetSessionDisplay) => {
2404
+ const expanded = session.expandable && expandedSessionIds.has(session.id);
2405
+ const tail = session.tail.slice(
2406
+ expanded ? -WIDGET_EXPANDED_TAIL_LINES : -WIDGET_COLLAPSED_TAIL_LINES,
2407
+ );
2408
+ const target = session.expandable ? { type: "session" as const, id: session.id } : undefined;
2409
+ if (!expanded && tail.length === 1) {
2410
+ push(`${session.header} ${tail[0]}`, target);
2411
+ } else {
2412
+ push(session.header, target);
2413
+ for (const line of tail) push(` ${line}`, target);
2414
+ if (expanded && tail.length === 0) push(" (no output available)", target);
2415
+ }
2416
+ };
2417
+
2418
+ for (const line of summaryLines) push(line);
2419
+ if (finished?.sessions.length) {
2420
+ const pageCount = Math.ceil(finished.sessions.length / WIDGET_FINISHED_PAGE_SIZE);
2421
+ finished.state.page = Math.min(Math.max(0, finished.state.page), pageCount - 1);
2422
+ push(
2423
+ ` FINISHED ${finished.sessions.length} — ${finished.state.open ? "hide" : "browse"} (click)`,
2424
+ { type: "finished" },
2425
+ );
2426
+ if (finished.state.open) {
2427
+ if (pageCount > 1) {
2428
+ if (finished.state.page > 0) push(" ← Previous", { type: "prev" });
2429
+ push(` ${finished.state.page === pageCount - 1 ? "↻ First" : "Next →"} (${finished.state.page + 1}/${pageCount})`, { type: "next" });
2430
+ }
2431
+ const start = finished.state.page * WIDGET_FINISHED_PAGE_SIZE;
2432
+ for (const session of finished.sessions.slice(start, start + WIDGET_FINISHED_PAGE_SIZE)) {
2433
+ pushSession(session);
2434
+ }
2435
+ }
2436
+ }
2437
+ for (const session of sessions) pushSession(session);
2438
+ return lines;
2439
+ },
2440
+ handleMouse(event: WidgetMouseEvent) {
2441
+ if (event.button !== "left") return undefined;
2442
+ const target = targetByLine[event.y];
2443
+ if (!target) return undefined;
2444
+ // Toggle on press so a terminal that omits release still works.
2445
+ if (event.type === "press") {
2446
+ if (target.type === "session") {
2447
+ if (expandedSessionIds.has(target.id)) expandedSessionIds.delete(target.id);
2448
+ else expandedSessionIds.add(target.id);
2449
+ } else if (finished) {
2450
+ if (target.type === "finished") finished.state.open = !finished.state.open;
2451
+ else if (target.type === "prev") finished.state.page--;
2452
+ else finished.state.page = (finished.state.page + 1) % Math.ceil(finished.sessions.length / WIDGET_FINISHED_PAGE_SIZE);
2453
+ }
2454
+ return { handled: true, render: true };
2455
+ }
2456
+ // Consume Pi's synthesized click without toggling a second time.
2457
+ if (event.type === "click") return { handled: true, render: false };
2458
+ return undefined;
2459
+ },
2460
+ invalidate() {
2461
+ targetByLine = [];
2462
+ },
2463
+ };
2464
+ }
2324
2465
 
2325
2466
  // Strip ANSI/control escapes and clamp width so raw PTY output can't wrap or
2326
2467
  // corrupt the widget area.
@@ -2362,20 +2503,23 @@ function readTailLines(file: string, lines: number, maxBytes = 64_000): string[]
2362
2503
  }
2363
2504
  }
2364
2505
 
2365
- // Trailing lines to show for a running session (sanitized, unprefixed).
2506
+ // Trailing lines to show for a session (sanitized, unprefixed).
2366
2507
  // Process tails are read directly from the bounded end of output.log, avoiding
2367
- // one `babysit log` subprocess per active process on every poll.
2368
- async function widgetTail(
2508
+ // one `babysit log` subprocess per visible process.
2509
+ export function widgetTail(
2369
2510
  id: string,
2370
2511
  isSub: boolean,
2371
2512
  subagentProgress?: Progress,
2372
- ): Promise<string[]> {
2513
+ lines = WIDGET_COLLAPSED_TAIL_LINES,
2514
+ ): string[] {
2373
2515
  let raw: string[];
2374
2516
  if (!isSub) {
2375
- raw = readTailLines(logPath(id), WIDGET_TAIL_LINES);
2517
+ raw = readTailLines(logPath(id), lines);
2376
2518
  } else {
2377
2519
  const progress = subagentProgress ?? taskProgressOf(id).progress;
2378
- if (progress.finalText.trim()) {
2520
+ if (progress.streamingText.trim()) {
2521
+ raw = progress.streamingText.trim().split("\n");
2522
+ } else if (progress.finalText.trim()) {
2379
2523
  raw = progress.finalText.trim().split("\n");
2380
2524
  } else if (progress.toolCalls.length > 0) {
2381
2525
  raw = progress.toolCalls.map((tool) => tool.summary);
@@ -2386,7 +2530,7 @@ async function widgetTail(
2386
2530
  return raw
2387
2531
  .map(sanitizeTailLine)
2388
2532
  .filter((line) => line.trim().length > 0)
2389
- .slice(-WIDGET_TAIL_LINES);
2533
+ .slice(-lines);
2390
2534
  }
2391
2535
 
2392
2536
  // ---------------------------------------------------------------------------
@@ -2413,6 +2557,63 @@ interface WaitOutcome {
2413
2557
  progress?: Progress;
2414
2558
  }
2415
2559
 
2560
+ export type ProcessLifecycle = "attached" | "detached";
2561
+
2562
+ export function resolveProcessLifecycle(
2563
+ requested: ProcessLifecycle | undefined,
2564
+ waitsInline: boolean,
2565
+ ): ProcessLifecycle {
2566
+ return requested ?? (waitsInline ? "attached" : "detached");
2567
+ }
2568
+
2569
+ async function terminateAttachedProcessAfterInterrupt(id: string): Promise<WaitOutcome> {
2570
+ const killed = await bs(["kill", "-s", id, "--json"]);
2571
+ const backendError =
2572
+ killed.code === 0
2573
+ ? validateKillResponse(killed.stdout)
2574
+ : killed.stderr.trim() || killed.stdout.trim() || `babysit kill exited ${killed.code}`;
2575
+ const status = await awaitConfirmedTermination(id);
2576
+ const confirmation = resolveKillConfirmation(id, backendError, status?.state);
2577
+ if (!confirmation.confirmed) {
2578
+ return {
2579
+ id,
2580
+ kind: "interrupted",
2581
+ ok: false,
2582
+ text:
2583
+ `Foreground process ${id} was interrupted, but attached lifecycle termination could not be verified: ${confirmation.error}\n` +
2584
+ `Log: ${logPath(id)}`,
2585
+ status,
2586
+ };
2587
+ }
2588
+
2589
+ // The interrupted owning call reports the termination, so the background
2590
+ // poller must not emit a second completion notification.
2591
+ suppressNotify(id, "kill");
2592
+ return {
2593
+ id,
2594
+ kind: "interrupted",
2595
+ ok: false,
2596
+ text:
2597
+ `Foreground process ${id} was interrupted; attached lifecycle cleanup confirmed terminal state.` +
2598
+ (confirmation.warning ? ` Backend warning: ${confirmation.warning}` : "") +
2599
+ `\nLog: ${logPath(id)}`,
2600
+ status,
2601
+ };
2602
+ }
2603
+
2604
+ async function waitForProcessWithLifecycle(
2605
+ id: string,
2606
+ signal: AbortSignal | undefined,
2607
+ lifecycle: ProcessLifecycle,
2608
+ outputSelection?: ProcessOutputSelection,
2609
+ ): Promise<WaitOutcome> {
2610
+ const outcome = await waitForExit(id, null, signal, undefined, outputSelection);
2611
+ if (lifecycle !== "attached" || !signal?.aborted || outcome.kind !== "interrupted") {
2612
+ return outcome;
2613
+ }
2614
+ return terminateAttachedProcessAfterInterrupt(id);
2615
+ }
2616
+
2416
2617
  export interface NestedUsage {
2417
2618
  input: number;
2418
2619
  output: number;
@@ -3215,11 +3416,13 @@ export default function (pi: ExtensionAPI) {
3215
3416
  }
3216
3417
  }
3217
3418
 
3419
+ const expandedWidgetSessions = new Set<string>();
3420
+ const finishedWidgetState: WidgetFinishedState = { open: false, page: 0 };
3421
+
3218
3422
  const refreshWidget = async (ctx: ExtensionContext, snapshot?: BsSession[]) => {
3219
3423
  if (!ctx.hasUI) return;
3220
- const active = (snapshot ?? (await listSessions()).sessions).filter(
3221
- (session) => session.state === "running",
3222
- );
3424
+ const all = snapshot ?? (await listSessions()).sessions;
3425
+ const { active, finished } = widgetSessionGroups(all, (id) => readMeta(id)?.startedAt ?? 0);
3223
3426
  const subs = active.filter((session) => kindOf(session.id) === "subagent");
3224
3427
  const procs = active.length - subs.length;
3225
3428
  const progressById = new Map<string, Progress>();
@@ -3231,8 +3434,13 @@ export default function (pi: ExtensionAPI) {
3231
3434
  }
3232
3435
  }
3233
3436
  const idle = subs.filter((session) => progressById.get(session.id)?.done).length;
3437
+ const sessionIds = new Set(all.map((session) => session.id));
3438
+ for (const id of expandedWidgetSessions) {
3439
+ if (!sessionIds.has(id)) expandedWidgetSessions.delete(id);
3440
+ }
3441
+
3234
3442
  const theme = ctx.ui.theme;
3235
- const lines = renderWidgetLines(procs, subs.length - idle, idle, theme);
3443
+ const summaryLines = renderWidgetLines(procs, subs.length - idle, idle, theme);
3236
3444
  const tails = await Promise.all(
3237
3445
  active.map((session) => {
3238
3446
  const isSubagent = kindOf(session.id) === "subagent";
@@ -3240,30 +3448,67 @@ export default function (pi: ExtensionAPI) {
3240
3448
  session.id,
3241
3449
  isSubagent,
3242
3450
  isSubagent ? progressById.get(session.id) : undefined,
3451
+ WIDGET_EXPANDED_TAIL_LINES,
3243
3452
  );
3244
3453
  }),
3245
3454
  );
3246
- active.forEach((session, index) => {
3455
+ const displays = active.map((session, index): WidgetSessionDisplay => {
3247
3456
  const isSubagent = kindOf(session.id) === "subagent";
3248
3457
  const state: WidgetSessionState = isSubagent && progressById.get(session.id)?.done
3249
3458
  ? "idle"
3250
3459
  : "running";
3251
- const elapsed = elapsedOf(session.id);
3252
- const header = renderWidgetSessionHeader(
3253
- session.id,
3254
- isSubagent ? "agent" : "process",
3255
- state,
3256
- elapsed,
3257
- theme,
3258
- );
3259
- if (tails[index].length === 1) {
3260
- lines.push(`${header} ${tails[index][0]}`);
3261
- } else {
3262
- lines.push(header);
3263
- for (const tail of tails[index]) lines.push(` ${tail}`);
3264
- }
3460
+ return {
3461
+ id: session.id,
3462
+ header: renderWidgetSessionHeader(
3463
+ session.id,
3464
+ isSubagent ? "agent" : "process",
3465
+ state,
3466
+ elapsedOf(session.id),
3467
+ theme,
3468
+ ),
3469
+ tail: tails[index],
3470
+ expandable: true,
3471
+ };
3472
+ });
3473
+ const finishedDisplays = finished.map((session): WidgetSessionDisplay => {
3474
+ const isSubagent = kindOf(session.id) === "subagent";
3475
+ const id = session.id;
3476
+ return {
3477
+ id,
3478
+ header: renderWidgetSessionHeader(
3479
+ id,
3480
+ isSubagent ? "agent" : "process",
3481
+ session.state === "exited" && session.exit_code === 0 ? "finished" : "failed",
3482
+ "",
3483
+ theme,
3484
+ ),
3485
+ // Only read/parse the five visible finished sessions on demand.
3486
+ get tail() {
3487
+ try {
3488
+ return widgetTail(id, isSubagent, undefined, WIDGET_EXPANDED_TAIL_LINES);
3489
+ } catch {
3490
+ return [];
3491
+ }
3492
+ },
3493
+ expandable: true,
3494
+ };
3265
3495
  });
3266
- ctx.ui.setWidget("pi-babysit", lines, { placement: "belowEditor" });
3496
+
3497
+ if (ctx.mode === "tui") {
3498
+ ctx.ui.setWidget(
3499
+ "pi-babysit",
3500
+ () => createWidgetComponent(summaryLines, displays, expandedWidgetSessions, {
3501
+ sessions: finishedDisplays,
3502
+ state: finishedWidgetState,
3503
+ }),
3504
+ { placement: "belowEditor" },
3505
+ );
3506
+ } else {
3507
+ const component = createWidgetComponent(summaryLines, displays, new Set());
3508
+ ctx.ui.setWidget("pi-babysit", component.render(10_000), {
3509
+ placement: "belowEditor",
3510
+ });
3511
+ }
3267
3512
  };
3268
3513
 
3269
3514
  type DisplayStatus = "started" | "running" | "idle" | "success" | "failed" | "terminated";
@@ -3603,6 +3848,12 @@ export default function (pi: ExtensionAPI) {
3603
3848
  description: "Process or subagent: wait for completion and return the result in this tool call.",
3604
3849
  }),
3605
3850
  ),
3851
+ lifecycle: Type.Optional(
3852
+ StringEnum(["attached", "detached"] as const, {
3853
+ description:
3854
+ "Process mode only. attached terminates the process tree if the owning inline babysit_run wait is interrupted; detached leaves it running. Defaults to attached for foreground/non-interactive waits and detached for background starts.",
3855
+ }),
3856
+ ),
3606
3857
  returnPattern: Type.Optional(
3607
3858
  Type.String({ description: "Foreground/quick process: return only latest regex matches." }),
3608
3859
  ),
@@ -3675,6 +3926,20 @@ export default function (pi: ExtensionAPI) {
3675
3926
  details: {},
3676
3927
  };
3677
3928
  }
3929
+ if (isSubagent && params.lifecycle) {
3930
+ return {
3931
+ content: [{ type: "text", text: "`lifecycle` is available only in process mode." }],
3932
+ isError: true,
3933
+ details: {},
3934
+ };
3935
+ }
3936
+ if (!isSubagent && params.lifecycle === "attached" && ctx.hasUI && !params.foreground) {
3937
+ return {
3938
+ content: [{ type: "text", text: "`lifecycle: attached` requires `foreground: true` in interactive process mode." }],
3939
+ isError: true,
3940
+ details: {},
3941
+ };
3942
+ }
3678
3943
  if (isSubagent && (params.returnPattern || params.returnLines != null || params.maxBytes != null)) {
3679
3944
  return {
3680
3945
  content: [{ type: "text", text: "`returnPattern`, `returnLines`, and `maxBytes` are process-output options." }],
@@ -3705,6 +3970,8 @@ export default function (pi: ExtensionAPI) {
3705
3970
 
3706
3971
  // --- process mode ---
3707
3972
  if (!isSubagent) {
3973
+ const waitsInline = !ctx.hasUI || params.foreground === true;
3974
+ const lifecycle = resolveProcessLifecycle(params.lifecycle, waitsInline);
3708
3975
  if (params.returnPattern) {
3709
3976
  try {
3710
3977
  new RegExp(params.returnPattern);
@@ -3746,19 +4013,19 @@ export default function (pi: ExtensionAPI) {
3746
4013
  // result in interactive mode, avoiding a separate babysit_wait model turn.
3747
4014
  // The command remains supervised, logged, killable, and subject to its
3748
4015
  // babysit timeout in either case.
3749
- if (!ctx.hasUI || params.foreground) {
4016
+ if (waitsInline) {
3750
4017
  // The babysit supervisor owns the absolute command timeout. Waiting with
3751
4018
  // the same deadline here races its terminal-state write and can return a
3752
4019
  // false "still running" result at the boundary, so wait for the
3753
4020
  // supervisor's definitive exit instead.
3754
- let outcome = await waitForExit(res.id, null, _signal, undefined, outputSelection);
4021
+ let outcome = await waitForProcessWithLifecycle(res.id, _signal, lifecycle, outputSelection);
3755
4022
  let retried = false;
3756
4023
  if (params.retryOnWorkerDeath && outcome.status?.state === "dead" && outcome.status.exit_code == null) {
3757
4024
  const retry = await spawnProcess(spawnOpts);
3758
4025
  if (!("error" in retry)) {
3759
4026
  res = retry;
3760
4027
  retried = true;
3761
- outcome = await waitForExit(res.id, null, _signal, undefined, outputSelection);
4028
+ outcome = await waitForProcessWithLifecycle(res.id, _signal, lifecycle, outputSelection);
3762
4029
  }
3763
4030
  }
3764
4031
  if (ctx.hasUI) await refreshWidget(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -19,14 +19,40 @@ import { pathToFileURL } from "node:url";
19
19
 
20
20
  function isParkedMessages(messages) {
21
21
  if (!Array.isArray(messages)) return false;
22
+ const terminalCounts = new Map();
23
+ const addTerminal = (id) => terminalCounts.set(id, (terminalCounts.get(id) ?? 0) + 1);
24
+
22
25
  for (let index = messages.length - 1; index >= 0; index--) {
23
26
  const message = messages[index];
24
27
  if (message?.role !== "toolResult") continue;
28
+ if (message.toolName === "babysit_wait") {
29
+ const status = message.details?.status;
30
+ if (
31
+ status &&
32
+ typeof status === "object" &&
33
+ typeof status.id === "string" &&
34
+ typeof status.state === "string" &&
35
+ status.state !== "running"
36
+ ) {
37
+ addTerminal(status.id);
38
+ }
39
+ for (const result of message.details?.results ?? []) {
40
+ if (result.kind === "exited" && typeof result.id === "string") addTerminal(result.id);
41
+ }
42
+ const first = message.details?.first;
43
+ if (first?.kind === "exited" && typeof first.id === "string") addTerminal(first.id);
44
+ continue;
45
+ }
46
+ if (
47
+ message.toolName === "babysit_kill" &&
48
+ typeof message.details?.id === "string" &&
49
+ ["exited", "killed", "dead"].includes(String(message.details.status))
50
+ ) {
51
+ addTerminal(message.details.id);
52
+ continue;
53
+ }
25
54
  if (message.toolName === "process") return true;
26
55
  if (message.toolName !== "babysit_run") continue;
27
- if (message.details?.kind === "process" && message.details.status === "started") {
28
- return true;
29
- }
30
56
  const text = Array.isArray(message.content)
31
57
  ? message.content
32
58
  .filter((part) => part?.type === "text" && typeof part.text === "string")
@@ -35,9 +61,16 @@ function isParkedMessages(messages) {
35
61
  : typeof message.content === "string"
36
62
  ? message.content
37
63
  : "";
38
- if (/^Process started \(id: [^)]+\)\. \[notify-on-exit\]\nLog: /.test(text)) {
39
- return true;
40
- }
64
+ const textMatch = /^Process started \(id: ([^)]+)\)\. \[notify-on-exit\]\nLog: /.exec(text);
65
+ const structuredStart =
66
+ message.details?.kind === "process" && message.details.status === "started";
67
+ if (!structuredStart && !textMatch) continue;
68
+ const id = typeof message.details?.id === "string" ? message.details.id : textMatch?.[1];
69
+ if (!id) return true;
70
+ const count = terminalCounts.get(id) ?? 0;
71
+ if (count === 0) return true;
72
+ if (count === 1) terminalCounts.delete(id);
73
+ else terminalCounts.set(id, count - 1);
41
74
  }
42
75
  return false;
43
76
  }
package/self-reap.ts CHANGED
@@ -36,25 +36,55 @@ function parseDurMs(s: string | undefined): number | null {
36
36
 
37
37
  // Same parked-turn rule as the parent: scan the run because models sometimes
38
38
  // add an assistant note after the marker-bearing tool result.
39
- export function isParked(
40
- messages:
41
- | {
42
- role?: string;
43
- toolName?: string;
44
- content?: unknown;
45
- details?: { kind?: string; status?: string };
46
- }[]
47
- | undefined,
48
- ): boolean {
39
+ type ParkedMessage = {
40
+ role?: string;
41
+ toolName?: string;
42
+ content?: unknown;
43
+ details?: {
44
+ id?: string;
45
+ kind?: string;
46
+ status?: string | { id?: string; state?: string };
47
+ results?: Array<{ id?: string; kind?: string }>;
48
+ first?: { id?: string; kind?: string };
49
+ };
50
+ };
51
+
52
+ export function isParked(messages: ParkedMessage[] | undefined): boolean {
49
53
  if (!messages) return false;
54
+ const terminalCounts = new Map<string, number>();
55
+ const addTerminal = (id: string) => terminalCounts.set(id, (terminalCounts.get(id) ?? 0) + 1);
56
+
50
57
  for (let index = messages.length - 1; index >= 0; index--) {
51
58
  const message = messages[index];
52
59
  if (message?.role !== "toolResult") continue;
60
+ if (message.toolName === "babysit_wait") {
61
+ const status = message.details?.status;
62
+ if (
63
+ status &&
64
+ typeof status === "object" &&
65
+ typeof status.id === "string" &&
66
+ typeof status.state === "string" &&
67
+ status.state !== "running"
68
+ ) {
69
+ addTerminal(status.id);
70
+ }
71
+ for (const result of message.details?.results ?? []) {
72
+ if (result.kind === "exited" && typeof result.id === "string") addTerminal(result.id);
73
+ }
74
+ const first = message.details?.first;
75
+ if (first?.kind === "exited" && typeof first.id === "string") addTerminal(first.id);
76
+ continue;
77
+ }
78
+ if (
79
+ message.toolName === "babysit_kill" &&
80
+ typeof message.details?.id === "string" &&
81
+ ["exited", "killed", "dead"].includes(String(message.details.status))
82
+ ) {
83
+ addTerminal(message.details.id);
84
+ continue;
85
+ }
53
86
  if (message.toolName === "process") return true; // legacy pi-processes
54
87
  if (message.toolName !== "babysit_run") continue;
55
- if (message.details?.kind === "process" && message.details.status === "started") {
56
- return true;
57
- }
58
88
  const text = Array.isArray(message.content)
59
89
  ? message.content
60
90
  .filter((part): part is { type: "text"; text: string } =>
@@ -65,7 +95,16 @@ export function isParked(
65
95
  : typeof message.content === "string"
66
96
  ? message.content
67
97
  : "";
68
- if (/^Process started \(id: [^)]+\)\. \[notify-on-exit\]\nLog: /.test(text)) return true;
98
+ const textMatch = /^Process started \(id: ([^)]+)\)\. \[notify-on-exit\]\nLog: /.exec(text);
99
+ const structuredStart =
100
+ message.details?.kind === "process" && message.details.status === "started";
101
+ if (!structuredStart && !textMatch) continue;
102
+ const id = typeof message.details?.id === "string" ? message.details.id : textMatch?.[1];
103
+ if (!id) return true;
104
+ const count = terminalCounts.get(id) ?? 0;
105
+ if (count === 0) return true;
106
+ if (count === 1) terminalCounts.delete(id);
107
+ else terminalCounts.set(id, count - 1);
69
108
  }
70
109
  return false;
71
110
  }
@@ -92,9 +131,7 @@ export default function (pi: ExtensionAPI) {
92
131
  });
93
132
 
94
133
  pi.on("agent_end", (event) => {
95
- const messages = (
96
- event as { messages?: { role?: string; toolName?: string; content?: unknown }[] }
97
- ).messages;
134
+ const messages = (event as { messages?: ParkedMessage[] }).messages;
98
135
  lastEndWasParked = isParked(messages);
99
136
  });
100
137