@yusukeshib/pi-babysit 0.6.4 → 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"` |
@@ -132,7 +132,20 @@ suite at the end. Background subagents must likewise be collected before the
132
132
  parent task finishes so their answer and nested usage are not lost.
133
133
  Set `PI_BABYSIT_ALLOW_BASH=1` only as an explicit emergency escape hatch.
134
134
 
135
- ## 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`.
136
149
 
137
150
  A real Pi quit terminates every running process and subagent in the current Pi
138
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";
@@ -3603,6 +3773,12 @@ export default function (pi: ExtensionAPI) {
3603
3773
  description: "Process or subagent: wait for completion and return the result in this tool call.",
3604
3774
  }),
3605
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
+ ),
3606
3782
  returnPattern: Type.Optional(
3607
3783
  Type.String({ description: "Foreground/quick process: return only latest regex matches." }),
3608
3784
  ),
@@ -3675,6 +3851,20 @@ export default function (pi: ExtensionAPI) {
3675
3851
  details: {},
3676
3852
  };
3677
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
+ }
3678
3868
  if (isSubagent && (params.returnPattern || params.returnLines != null || params.maxBytes != null)) {
3679
3869
  return {
3680
3870
  content: [{ type: "text", text: "`returnPattern`, `returnLines`, and `maxBytes` are process-output options." }],
@@ -3705,6 +3895,8 @@ export default function (pi: ExtensionAPI) {
3705
3895
 
3706
3896
  // --- process mode ---
3707
3897
  if (!isSubagent) {
3898
+ const waitsInline = !ctx.hasUI || params.foreground === true;
3899
+ const lifecycle = resolveProcessLifecycle(params.lifecycle, waitsInline);
3708
3900
  if (params.returnPattern) {
3709
3901
  try {
3710
3902
  new RegExp(params.returnPattern);
@@ -3746,19 +3938,19 @@ export default function (pi: ExtensionAPI) {
3746
3938
  // result in interactive mode, avoiding a separate babysit_wait model turn.
3747
3939
  // The command remains supervised, logged, killable, and subject to its
3748
3940
  // babysit timeout in either case.
3749
- if (!ctx.hasUI || params.foreground) {
3941
+ if (waitsInline) {
3750
3942
  // The babysit supervisor owns the absolute command timeout. Waiting with
3751
3943
  // the same deadline here races its terminal-state write and can return a
3752
3944
  // false "still running" result at the boundary, so wait for the
3753
3945
  // supervisor's definitive exit instead.
3754
- let outcome = await waitForExit(res.id, null, _signal, undefined, outputSelection);
3946
+ let outcome = await waitForProcessWithLifecycle(res.id, _signal, lifecycle, outputSelection);
3755
3947
  let retried = false;
3756
3948
  if (params.retryOnWorkerDeath && outcome.status?.state === "dead" && outcome.status.exit_code == null) {
3757
3949
  const retry = await spawnProcess(spawnOpts);
3758
3950
  if (!("error" in retry)) {
3759
3951
  res = retry;
3760
3952
  retried = true;
3761
- outcome = await waitForExit(res.id, null, _signal, undefined, outputSelection);
3953
+ outcome = await waitForProcessWithLifecycle(res.id, _signal, lifecycle, outputSelection);
3762
3954
  }
3763
3955
  }
3764
3956
  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.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