@yusukeshib/pi-babysit 0.6.3 → 0.6.5

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"` |
@@ -81,7 +81,9 @@ backgrounding (`… &`, `nohup`, `setsid`, `disown`). Set
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
83
  `PROCESS RUNNING`, `AGENT RUNNING`, or `AGENT IDLE`. The displayed `age`
84
- is total session age, not time spent in the current state.
84
+ is total session age, not time spent in the current state. Transcript details
85
+ from tool results, snapshots, and completion notifications collapse or expand
86
+ with Pi's tool-output toggle (`Ctrl+O` by default).
85
87
 
86
88
  ## Logs without context flooding
87
89
 
@@ -130,7 +132,20 @@ suite at the end. Background subagents must likewise be collected before the
130
132
  parent task finishes so their answer and nested usage are not lost.
131
133
  Set `PI_BABYSIT_ALLOW_BASH=1` only as an explicit emergency escape hatch.
132
134
 
133
- ## Pi shutdown and reload
135
+ ## Process ownership, interruption, shutdown, and reload
136
+
137
+ An inline process wait (`foreground: true`, or process execution in a non-UI
138
+ mode) defaults to `lifecycle: "attached"`. If its owning `babysit_run` tool call
139
+ is interrupted through Pi's AbortSignal, pi-babysit terminates the supervised
140
+ process tree through the babysit session API and verifies persisted terminal
141
+ state before returning. Set `lifecycle: "detached"` explicitly only when a
142
+ foreground wait may be interrupted but the process must continue. Interactive
143
+ background starts are detached; `lifecycle: "attached"` therefore requires
144
+ `foreground: true` in interactive mode.
145
+
146
+ An explicit `babysit_wait` is only a waiter, not the process owner. Interrupting
147
+ it never terminates the process; call `babysit_kill` when termination is wanted.
148
+ This cooperative policy does not claim to detect an abrupt host `SIGKILL`.
134
149
 
135
150
  A real Pi quit terminates every running process and subagent in the current Pi
136
151
  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":
@@ -2319,9 +2346,74 @@ function renderWidgetSessionHeader(
2319
2346
  }
2320
2347
 
2321
2348
  // How many trailing output lines to show per running session in the widget.
2322
- const WIDGET_TAIL_LINES = 1;
2349
+ const WIDGET_COLLAPSED_TAIL_LINES = 1;
2350
+ const WIDGET_EXPANDED_TAIL_LINES = 20;
2323
2351
  const WIDGET_TAIL_WIDTH = 100;
2324
2352
 
2353
+ export interface WidgetSessionDisplay {
2354
+ id: string;
2355
+ header: string;
2356
+ tail: string[];
2357
+ expandable: boolean;
2358
+ }
2359
+
2360
+ interface WidgetMouseEvent {
2361
+ type: string;
2362
+ button: string;
2363
+ y: number;
2364
+ }
2365
+
2366
+ /**
2367
+ * Build the live widget as a mouse-aware component without depending on newer
2368
+ * pi-tui mouse classes. Older hosts still render it normally; fullscreen hosts
2369
+ * dispatch component-local click coordinates to handleMouse.
2370
+ */
2371
+ export function createWidgetComponent(
2372
+ summaryLines: string[],
2373
+ sessions: WidgetSessionDisplay[],
2374
+ expandedProcessIds: Set<string>,
2375
+ ) {
2376
+ let processIdByLine: Array<string | undefined> = [];
2377
+
2378
+ return {
2379
+ render(width: number): string[] {
2380
+ const lines: string[] = [];
2381
+ processIdByLine = [];
2382
+ const push = (text: string, processId?: string) => {
2383
+ lines.push(truncateToWidth(text, Math.max(0, width), ""));
2384
+ processIdByLine.push(processId);
2385
+ };
2386
+
2387
+ for (const line of summaryLines) push(line);
2388
+ for (const session of sessions) {
2389
+ const expanded = session.expandable && expandedProcessIds.has(session.id);
2390
+ const tail = session.tail.slice(
2391
+ expanded ? -WIDGET_EXPANDED_TAIL_LINES : -WIDGET_COLLAPSED_TAIL_LINES,
2392
+ );
2393
+ const processId = session.expandable ? session.id : undefined;
2394
+ if (!expanded && tail.length === 1) {
2395
+ push(`${session.header} ${tail[0]}`, processId);
2396
+ } else {
2397
+ push(session.header, processId);
2398
+ for (const line of tail) push(` ${line}`, processId);
2399
+ }
2400
+ }
2401
+ return lines;
2402
+ },
2403
+ handleMouse(event: WidgetMouseEvent) {
2404
+ if (event.type !== "click" || event.button !== "left") return undefined;
2405
+ const id = processIdByLine[event.y];
2406
+ if (!id) return undefined;
2407
+ if (expandedProcessIds.has(id)) expandedProcessIds.delete(id);
2408
+ else expandedProcessIds.add(id);
2409
+ return { handled: true, render: true };
2410
+ },
2411
+ invalidate() {
2412
+ processIdByLine = [];
2413
+ },
2414
+ };
2415
+ }
2416
+
2325
2417
  // Strip ANSI/control escapes and clamp width so raw PTY output can't wrap or
2326
2418
  // corrupt the widget area.
2327
2419
  function sanitizeTailLine(s: string): string {
@@ -2369,10 +2461,11 @@ async function widgetTail(
2369
2461
  id: string,
2370
2462
  isSub: boolean,
2371
2463
  subagentProgress?: Progress,
2464
+ lines = WIDGET_COLLAPSED_TAIL_LINES,
2372
2465
  ): Promise<string[]> {
2373
2466
  let raw: string[];
2374
2467
  if (!isSub) {
2375
- raw = readTailLines(logPath(id), WIDGET_TAIL_LINES);
2468
+ raw = readTailLines(logPath(id), lines);
2376
2469
  } else {
2377
2470
  const progress = subagentProgress ?? taskProgressOf(id).progress;
2378
2471
  if (progress.finalText.trim()) {
@@ -2386,7 +2479,7 @@ async function widgetTail(
2386
2479
  return raw
2387
2480
  .map(sanitizeTailLine)
2388
2481
  .filter((line) => line.trim().length > 0)
2389
- .slice(-WIDGET_TAIL_LINES);
2482
+ .slice(-lines);
2390
2483
  }
2391
2484
 
2392
2485
  // ---------------------------------------------------------------------------
@@ -2413,6 +2506,63 @@ interface WaitOutcome {
2413
2506
  progress?: Progress;
2414
2507
  }
2415
2508
 
2509
+ export type ProcessLifecycle = "attached" | "detached";
2510
+
2511
+ export function resolveProcessLifecycle(
2512
+ requested: ProcessLifecycle | undefined,
2513
+ waitsInline: boolean,
2514
+ ): ProcessLifecycle {
2515
+ return requested ?? (waitsInline ? "attached" : "detached");
2516
+ }
2517
+
2518
+ async function terminateAttachedProcessAfterInterrupt(id: string): Promise<WaitOutcome> {
2519
+ const killed = await bs(["kill", "-s", id, "--json"]);
2520
+ const backendError =
2521
+ killed.code === 0
2522
+ ? validateKillResponse(killed.stdout)
2523
+ : killed.stderr.trim() || killed.stdout.trim() || `babysit kill exited ${killed.code}`;
2524
+ const status = await awaitConfirmedTermination(id);
2525
+ const confirmation = resolveKillConfirmation(id, backendError, status?.state);
2526
+ if (!confirmation.confirmed) {
2527
+ return {
2528
+ id,
2529
+ kind: "interrupted",
2530
+ ok: false,
2531
+ text:
2532
+ `Foreground process ${id} was interrupted, but attached lifecycle termination could not be verified: ${confirmation.error}\n` +
2533
+ `Log: ${logPath(id)}`,
2534
+ status,
2535
+ };
2536
+ }
2537
+
2538
+ // The interrupted owning call reports the termination, so the background
2539
+ // poller must not emit a second completion notification.
2540
+ suppressNotify(id, "kill");
2541
+ return {
2542
+ id,
2543
+ kind: "interrupted",
2544
+ ok: false,
2545
+ text:
2546
+ `Foreground process ${id} was interrupted; attached lifecycle cleanup confirmed terminal state.` +
2547
+ (confirmation.warning ? ` Backend warning: ${confirmation.warning}` : "") +
2548
+ `\nLog: ${logPath(id)}`,
2549
+ status,
2550
+ };
2551
+ }
2552
+
2553
+ async function waitForProcessWithLifecycle(
2554
+ id: string,
2555
+ signal: AbortSignal | undefined,
2556
+ lifecycle: ProcessLifecycle,
2557
+ outputSelection?: ProcessOutputSelection,
2558
+ ): Promise<WaitOutcome> {
2559
+ const outcome = await waitForExit(id, null, signal, undefined, outputSelection);
2560
+ if (lifecycle !== "attached" || !signal?.aborted || outcome.kind !== "interrupted") {
2561
+ return outcome;
2562
+ }
2563
+ return terminateAttachedProcessAfterInterrupt(id);
2564
+ }
2565
+
2416
2566
  export interface NestedUsage {
2417
2567
  input: number;
2418
2568
  output: number;
@@ -3215,6 +3365,8 @@ export default function (pi: ExtensionAPI) {
3215
3365
  }
3216
3366
  }
3217
3367
 
3368
+ const expandedWidgetProcesses = new Set<string>();
3369
+
3218
3370
  const refreshWidget = async (ctx: ExtensionContext, snapshot?: BsSession[]) => {
3219
3371
  if (!ctx.hasUI) return;
3220
3372
  const active = (snapshot ?? (await listSessions()).sessions).filter(
@@ -3231,8 +3383,15 @@ export default function (pi: ExtensionAPI) {
3231
3383
  }
3232
3384
  }
3233
3385
  const idle = subs.filter((session) => progressById.get(session.id)?.done).length;
3386
+ const activeProcessIds = new Set(
3387
+ active.filter((session) => kindOf(session.id) !== "subagent").map((session) => session.id),
3388
+ );
3389
+ for (const id of expandedWidgetProcesses) {
3390
+ if (!activeProcessIds.has(id)) expandedWidgetProcesses.delete(id);
3391
+ }
3392
+
3234
3393
  const theme = ctx.ui.theme;
3235
- const lines = renderWidgetLines(procs, subs.length - idle, idle, theme);
3394
+ const summaryLines = renderWidgetLines(procs, subs.length - idle, idle, theme);
3236
3395
  const tails = await Promise.all(
3237
3396
  active.map((session) => {
3238
3397
  const isSubagent = kindOf(session.id) === "subagent";
@@ -3240,30 +3399,41 @@ export default function (pi: ExtensionAPI) {
3240
3399
  session.id,
3241
3400
  isSubagent,
3242
3401
  isSubagent ? progressById.get(session.id) : undefined,
3402
+ isSubagent ? WIDGET_COLLAPSED_TAIL_LINES : WIDGET_EXPANDED_TAIL_LINES,
3243
3403
  );
3244
3404
  }),
3245
3405
  );
3246
- active.forEach((session, index) => {
3406
+ const displays = active.map((session, index): WidgetSessionDisplay => {
3247
3407
  const isSubagent = kindOf(session.id) === "subagent";
3248
3408
  const state: WidgetSessionState = isSubagent && progressById.get(session.id)?.done
3249
3409
  ? "idle"
3250
3410
  : "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
- }
3411
+ return {
3412
+ id: session.id,
3413
+ header: renderWidgetSessionHeader(
3414
+ session.id,
3415
+ isSubagent ? "agent" : "process",
3416
+ state,
3417
+ elapsedOf(session.id),
3418
+ theme,
3419
+ ),
3420
+ tail: tails[index],
3421
+ expandable: !isSubagent,
3422
+ };
3265
3423
  });
3266
- ctx.ui.setWidget("pi-babysit", lines, { placement: "belowEditor" });
3424
+
3425
+ if (ctx.mode === "tui") {
3426
+ ctx.ui.setWidget(
3427
+ "pi-babysit",
3428
+ () => createWidgetComponent(summaryLines, displays, expandedWidgetProcesses),
3429
+ { placement: "belowEditor" },
3430
+ );
3431
+ } else {
3432
+ const component = createWidgetComponent(summaryLines, displays, new Set());
3433
+ ctx.ui.setWidget("pi-babysit", component.render(10_000), {
3434
+ placement: "belowEditor",
3435
+ });
3436
+ }
3267
3437
  };
3268
3438
 
3269
3439
  type DisplayStatus = "started" | "running" | "idle" | "success" | "failed" | "terminated";
@@ -3294,7 +3464,7 @@ export default function (pi: ExtensionAPI) {
3294
3464
 
3295
3465
  // Render snapshots and subagent answers INLINE in the transcript as formatted
3296
3466
  // markdown, with a semantic status label that remains readable on any theme.
3297
- pi.registerMessageRenderer("pi-babysit-result", (message, _opts, theme) => {
3467
+ pi.registerMessageRenderer("pi-babysit-result", (message, { expanded }, theme) => {
3298
3468
  const d = (message.details ?? {}) as {
3299
3469
  title?: string;
3300
3470
  body?: string;
@@ -3305,35 +3475,44 @@ export default function (pi: ExtensionAPI) {
3305
3475
  const box = new Box(1, 0, (t) => theme.bg("toolSuccessBg", t));
3306
3476
  if (d.status) box.addChild(new Text(renderStatus(d.status, theme), 0, 0));
3307
3477
  if (d.title) box.addChild(new Text(theme.fg("accent", d.title), 0, 0));
3308
- box.addChild(new Markdown(body, 0, 0, getMarkdownTheme()));
3478
+ if (expanded && body) box.addChild(new Markdown(body, 0, 0, getMarkdownTheme()));
3309
3479
  return box;
3310
3480
  });
3311
3481
 
3312
3482
  // Process-end notification rendering with a colored lifecycle label. Keep the
3313
3483
  // box background subtle: coloring a potentially large log excerpt is noisy.
3314
- pi.registerMessageRenderer("pi-babysit-process-end", (message, _opts, theme) => {
3315
- const content = typeof message.content === "string" ? message.content : "";
3316
- const d = (message.details ?? {}) as {
3317
- status?: DisplayStatus;
3318
- success?: boolean;
3319
- exitCode?: number | null;
3320
- command?: string;
3321
- count?: number;
3322
- };
3323
- const status =
3324
- d.status ?? (d.success ? "success" : d.exitCode == null ? "terminated" : "failed");
3325
- const payload = d.command
3326
- ? ` ${summarizeNotificationCommand(d.command)}`
3327
- : d.count && d.count > 1
3328
- ? ` ×${d.count}`
3329
- : "";
3330
- const header = theme.fg("warning", theme.bold(`babysit_run COMMAND${payload}`));
3331
- const box = new Box(1, 1, (t) => theme.bg("toolSuccessBg", t));
3332
- box.addChild(new Text(header, 0, 0));
3333
- box.addChild(new Text(renderStatus(status, theme), 0, 0));
3334
- box.addChild(new Text(theme.fg("toolOutput", content), 0, 0));
3335
- return box;
3336
- });
3484
+ pi.registerMessageRenderer(
3485
+ "pi-babysit-process-end",
3486
+ (message, { expanded }, theme) => {
3487
+ const content = typeof message.content === "string" ? message.content : "";
3488
+ const d = (message.details ?? {}) as {
3489
+ status?: DisplayStatus;
3490
+ success?: boolean;
3491
+ exitCode?: number | null;
3492
+ command?: string;
3493
+ count?: number;
3494
+ };
3495
+ const status =
3496
+ d.status ??
3497
+ (d.success ? "success" : d.exitCode == null ? "terminated" : "failed");
3498
+ const payload = d.command
3499
+ ? ` ${summarizeNotificationCommand(d.command)}`
3500
+ : d.count && d.count > 1
3501
+ ? ` ×${d.count}`
3502
+ : "";
3503
+ const header = theme.fg(
3504
+ "warning",
3505
+ theme.bold(`babysit_run COMMAND${payload}`),
3506
+ );
3507
+ const box = new Box(1, 1, (t) => theme.bg("toolSuccessBg", t));
3508
+ box.addChild(new Text(header, 0, 0));
3509
+ box.addChild(new Text(renderStatus(status, theme), 0, 0));
3510
+ if (expanded && content) {
3511
+ box.addChild(new Text(theme.fg("toolOutput", content), 0, 0));
3512
+ }
3513
+ return box;
3514
+ },
3515
+ );
3337
3516
 
3338
3517
  let polling = false;
3339
3518
  pi.on("session_start", async (_event, ctx) => {
@@ -3594,6 +3773,12 @@ export default function (pi: ExtensionAPI) {
3594
3773
  description: "Process or subagent: wait for completion and return the result in this tool call.",
3595
3774
  }),
3596
3775
  ),
3776
+ lifecycle: Type.Optional(
3777
+ StringEnum(["attached", "detached"] as const, {
3778
+ description:
3779
+ "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.",
3780
+ }),
3781
+ ),
3597
3782
  returnPattern: Type.Optional(
3598
3783
  Type.String({ description: "Foreground/quick process: return only latest regex matches." }),
3599
3784
  ),
@@ -3666,6 +3851,20 @@ export default function (pi: ExtensionAPI) {
3666
3851
  details: {},
3667
3852
  };
3668
3853
  }
3854
+ if (isSubagent && params.lifecycle) {
3855
+ return {
3856
+ content: [{ type: "text", text: "`lifecycle` is available only in process mode." }],
3857
+ isError: true,
3858
+ details: {},
3859
+ };
3860
+ }
3861
+ if (!isSubagent && params.lifecycle === "attached" && ctx.hasUI && !params.foreground) {
3862
+ return {
3863
+ content: [{ type: "text", text: "`lifecycle: attached` requires `foreground: true` in interactive process mode." }],
3864
+ isError: true,
3865
+ details: {},
3866
+ };
3867
+ }
3669
3868
  if (isSubagent && (params.returnPattern || params.returnLines != null || params.maxBytes != null)) {
3670
3869
  return {
3671
3870
  content: [{ type: "text", text: "`returnPattern`, `returnLines`, and `maxBytes` are process-output options." }],
@@ -3696,6 +3895,8 @@ export default function (pi: ExtensionAPI) {
3696
3895
 
3697
3896
  // --- process mode ---
3698
3897
  if (!isSubagent) {
3898
+ const waitsInline = !ctx.hasUI || params.foreground === true;
3899
+ const lifecycle = resolveProcessLifecycle(params.lifecycle, waitsInline);
3699
3900
  if (params.returnPattern) {
3700
3901
  try {
3701
3902
  new RegExp(params.returnPattern);
@@ -3737,19 +3938,19 @@ export default function (pi: ExtensionAPI) {
3737
3938
  // result in interactive mode, avoiding a separate babysit_wait model turn.
3738
3939
  // The command remains supervised, logged, killable, and subject to its
3739
3940
  // babysit timeout in either case.
3740
- if (!ctx.hasUI || params.foreground) {
3941
+ if (waitsInline) {
3741
3942
  // The babysit supervisor owns the absolute command timeout. Waiting with
3742
3943
  // the same deadline here races its terminal-state write and can return a
3743
3944
  // false "still running" result at the boundary, so wait for the
3744
3945
  // supervisor's definitive exit instead.
3745
- let outcome = await waitForExit(res.id, null, _signal, undefined, outputSelection);
3946
+ let outcome = await waitForProcessWithLifecycle(res.id, _signal, lifecycle, outputSelection);
3746
3947
  let retried = false;
3747
3948
  if (params.retryOnWorkerDeath && outcome.status?.state === "dead" && outcome.status.exit_code == null) {
3748
3949
  const retry = await spawnProcess(spawnOpts);
3749
3950
  if (!("error" in retry)) {
3750
3951
  res = retry;
3751
3952
  retried = true;
3752
- outcome = await waitForExit(res.id, null, _signal, undefined, outputSelection);
3953
+ outcome = await waitForProcessWithLifecycle(res.id, _signal, lifecycle, outputSelection);
3753
3954
  }
3754
3955
  }
3755
3956
  if (ctx.hasUI) await refreshWidget(ctx);
@@ -3950,7 +4151,7 @@ export default function (pi: ExtensionAPI) {
3950
4151
  : "";
3951
4152
  return new Text(theme.fg("warning", `${title}${agent}${detail}`), 0, 0);
3952
4153
  },
3953
- renderResult(result, { isPartial }, theme, context) {
4154
+ renderResult(result, { expanded, isPartial }, theme, context) {
3954
4155
  const details = (result.details ?? {}) as {
3955
4156
  kind?: "process" | "subagent";
3956
4157
  status?: DisplayStatus;
@@ -3977,7 +4178,11 @@ export default function (pi: ExtensionAPI) {
3977
4178
  ? "started"
3978
4179
  : "success");
3979
4180
  const label = renderStatus(status, theme);
3980
- return new Text(content ? `${label}\n${theme.fg("toolOutput", content)}` : label, 0, 0);
4181
+ return new Text(
4182
+ expanded && content ? `${label}\n${theme.fg("toolOutput", content)}` : label,
4183
+ 0,
4184
+ 0,
4185
+ );
3981
4186
  },
3982
4187
  });
3983
4188
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
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