@yusukeshib/pi-babysit 0.4.0 → 0.5.0

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.
Files changed (3) hide show
  1. package/README.md +12 -4
  2. package/index.ts +165 -23
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -33,7 +33,7 @@ reachable from anywhere (`~/.pi-babysit/<pi-session-id>/`). Two kinds:
33
33
  | kind | started by | completion | on completion |
34
34
  | ---- | ---------- | ---------- | ------------- |
35
35
  | **process** | `babysit_run { command }` | process **exit** | automatic notification message (`triggerTurn`), batched for all exits observed in the same poll — the agent may end its turn after starting and is resumed on exit, same contract as the old `process` tool |
36
- | **subagent** | `babysit_run { profile: "subagent", task }` | `agent_settled` in the RPC event stream (worker remains reusable during its idle grace) | none — the agent polls `babysit_check` or blocks on `babysit_wait`; the idle session accepts follow-up tasks until self-reap |
36
+ | **subagent** | `babysit_run { profile: "subagent", task }` | `agent_settled` in the RPC event stream (worker remains reusable during its idle grace) | `foreground: true` returns the answer and nested usage in the same tool call; background workers must be collected with `babysit_wait` before the parent finishes, and remain reusable until self-reap |
37
37
 
38
38
  The **profile is a tool parameter, not a separate tool set**: domain knowledge
39
39
  (RPC bookkeeping, per-task byte offsets, parked-turn detection, PTY-safe
@@ -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` when the next step needs the result in the same tool call; use `returnPattern`/`returnLines`/`maxBytes` to keep noisy output bounded without a second check turn. Or start a named subagent (`profile: "subagent"`, `task`, optional `name`/`agent`/`model`/`tools`/`maxDepth` and budget fields). `maxDepth` defaults to 1. Quick commands return inline; longer ones notify in the background |
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. 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"` |
@@ -117,7 +117,12 @@ Set `PI_BABYSIT_RPC_LOG_MODE=standard` only when legacy lifecycle payloads are
117
117
  needed for debugging. Live `/babysit` and attach views render either format.
118
118
 
119
119
  All shell commands, including `pwd` and Git, go through `babysit_run`. Bundle
120
- closely related tiny observations when doing so safely reduces tool turns.
120
+ closely related tiny observations when doing so safely reduces tool turns. For
121
+ parallel checks, do not issue sibling `foreground: true` calls: start them with
122
+ `continueAfterStart: true` and collect them in one multi-session `babysit_wait`.
123
+ During fix loops, prefer targeted checks after each edit and one full validation
124
+ suite at the end. Background subagents must likewise be collected before the
125
+ parent task finishes so their answer and nested usage are not lost.
121
126
  Set `PI_BABYSIT_ALLOW_BASH=1` only as an explicit emergency escape hatch.
122
127
 
123
128
  ## Unexpected worker loss
@@ -143,7 +148,10 @@ because blindly rerunning an arbitrary command can duplicate side effects.
143
148
  their exits span multiple polls.
144
149
  `babysit_kill` and an exit already reported by `babysit_wait` suppress the
145
150
  notification.
146
- - **Subagent**: `babysit_wait` blocks on `babysit expect '"type":"agent_settled"'`.
151
+ - **Subagent**: `foreground: true` or `babysit_wait` blocks on
152
+ `babysit expect '"type":"agent_settled"'`. A background task that settles or
153
+ exits before it is collected emits one ready-to-collect reminder; the parent
154
+ must still call `babysit_wait` so the answer and nested usage are persisted.
147
155
  Unlike `agent_end`, `agent_settled` cannot precede an automatic retry,
148
156
  compaction retry, or queued continuation. A settled run containing a
149
157
  **parked** toolResult — a `babysit_run { command }` result carrying the
package/index.ts CHANGED
@@ -418,6 +418,13 @@ interface Meta {
418
418
  budgetKilled?: boolean;
419
419
  /** Prompt offset whose nested usage has already been charged to the parent session. */
420
420
  usageReportedOffset?: number;
421
+ /** Prompt offset explicitly collected by foreground mode or babysit_wait. */
422
+ subagentCollectedOffset?: number;
423
+ /** Prompt offset whose ready-to-collect reminder was sent to the parent. */
424
+ subagentNotifiedOffset?: number;
425
+ /** Current task completion first observed by the reminder poller. */
426
+ subagentCompletionObservedOffset?: number;
427
+ subagentCompletionObservedAt?: number;
421
428
  }
422
429
 
423
430
  const metaDir = () => path.join(ROOT, "meta");
@@ -731,6 +738,16 @@ export function shouldDeliverProcessCompletion(
731
738
  return meta?.kind === "process" && !meta.notified && !meta.notificationPaused;
732
739
  }
733
740
 
741
+ export function shouldDeliverSubagentCompletion(meta: Meta | null): meta is Meta & { kind: "subagent" } {
742
+ if (meta?.kind !== "subagent") return false;
743
+ const offset = meta.promptOffset ?? 0;
744
+ return (
745
+ meta.usageReportedOffset !== offset &&
746
+ meta.subagentCollectedOffset !== offset &&
747
+ meta.subagentNotifiedOffset !== offset
748
+ );
749
+ }
750
+
734
751
  export function isNotificationGroupReady(
735
752
  meta: Meta,
736
753
  sessions: BsSession[],
@@ -752,10 +769,14 @@ export function shouldKeepPolling(
752
769
  sessions: Array<{ id: string; state: string }>,
753
770
  metaFor: (id: string) => Meta | null,
754
771
  ): boolean {
755
- return sessions.some(
756
- (session) =>
757
- session.state === "running" || shouldDeliverProcessCompletion(metaFor(session.id)),
758
- );
772
+ return sessions.some((session) => {
773
+ const meta = metaFor(session.id);
774
+ return (
775
+ session.state === "running" ||
776
+ shouldDeliverProcessCompletion(meta) ||
777
+ (session.state !== "running" && shouldDeliverSubagentCompletion(meta))
778
+ );
779
+ });
759
780
  }
760
781
 
761
782
  export function shouldKeepPollingAfterList(
@@ -2457,16 +2478,27 @@ async function waitForTask(
2457
2478
  }
2458
2479
  }
2459
2480
 
2460
- // Mark a process session as already-reported so the exit-notification poller
2461
- // doesn't send a duplicate message for something the agent just observed.
2481
+ // Mark a session as already reported so completion pollers do not send a
2482
+ // duplicate message for something the agent just observed.
2462
2483
  function suppressNotify(id: string, reason: "observed" | "kill" = "observed"): void {
2463
2484
  const meta = readMeta(id);
2464
- if (meta && meta.kind === "process") {
2485
+ if (!meta) return;
2486
+ if (meta.kind === "process") {
2465
2487
  meta.notified = true;
2466
2488
  if (reason === "kill") meta.killNotificationSuppressed = true;
2467
2489
  delete meta.notificationPaused;
2468
- writeMeta(id, meta);
2490
+ } else {
2491
+ meta.subagentCollectedOffset = meta.promptOffset ?? 0;
2469
2492
  }
2493
+ writeMeta(id, meta);
2494
+ }
2495
+
2496
+ function collectSubagentOutcome(outcome: WaitOutcome): void {
2497
+ if (outcome.kind !== "done" && outcome.kind !== "exited") return;
2498
+ const meta = readMeta(outcome.id);
2499
+ if (!meta || meta.kind !== "subagent") return;
2500
+ meta.subagentCollectedOffset = meta.promptOffset ?? 0;
2501
+ writeMeta(outcome.id, meta);
2470
2502
  }
2471
2503
 
2472
2504
  export interface WaitReservationState {
@@ -2574,10 +2606,23 @@ async function waitForExit(
2574
2606
  // one concurrent wait timing out cannot re-enable notifications underneath
2575
2607
  // another wait that is still pending.
2576
2608
  updateWaitReservation(id, "reserve");
2577
- const w = await bs(["wait", "-s", id, "--timeout", t], { signal });
2578
- if (signal?.aborted) {
2579
- updateWaitReservation(id, "abandon");
2580
- return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
2609
+ let w: Awaited<ReturnType<typeof bs>>;
2610
+ let attempt = 0;
2611
+ for (;;) {
2612
+ w = await bs(["wait", "-s", id, "--timeout", t], { signal });
2613
+ if (signal?.aborted) {
2614
+ updateWaitReservation(id, "abandon");
2615
+ return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
2616
+ }
2617
+ if (w.code === 0 || w.code === 124 || w.code === 130) break;
2618
+
2619
+ // A freshly spawned session can be visible in `list` before the backend's
2620
+ // wait endpoint is ready, especially when sibling foreground tools start
2621
+ // concurrently. Retry that transient startup race instead of reporting the
2622
+ // still-running child as "exited with code ?".
2623
+ const retryStatus = await statusOf(id);
2624
+ if (retryStatus?.state !== "running" || attempt++ >= 3) break;
2625
+ await new Promise((resolve) => setTimeout(resolve, 50 * attempt));
2581
2626
  }
2582
2627
  if (w.code === 130) {
2583
2628
  const interruptedStatus = await statusOf(id);
@@ -2619,6 +2664,18 @@ async function waitForExit(
2619
2664
  if (!expectPattern) updateWaitReservation(id, "claim");
2620
2665
  return { id, kind: "exited", ok: false, text: `No such session: ${id}` };
2621
2666
  }
2667
+ if (st.state === "running") {
2668
+ if (!expectPattern) updateWaitReservation(id, "abandon");
2669
+ return {
2670
+ id,
2671
+ kind: "interrupted",
2672
+ ok: false,
2673
+ text:
2674
+ `The wait backend returned before process ${id} exited; the process is still running. ` +
2675
+ `Use babysit_wait to continue waiting.\nLog: ${logPath(id)}`,
2676
+ status: st,
2677
+ };
2678
+ }
2622
2679
  if (expectPattern) suppressNotify(id);
2623
2680
  else updateWaitReservation(id, "claim"); // the agent sees the exit here; don't notify again
2624
2681
  const meta = readMeta(id);
@@ -2964,6 +3021,64 @@ export default function (pi: ExtensionAPI) {
2964
3021
  );
2965
3022
  }
2966
3023
 
3024
+ async function notifySettledSubagents(
3025
+ ctx: ExtensionContext,
3026
+ snapshot?: BsSession[],
3027
+ ): Promise<void> {
3028
+ if (shouldDeferCompletionNotification(ctx.isIdle())) return;
3029
+ const sessions = snapshot ?? (await listSessions()).sessions;
3030
+ const ready: Array<{ id: string; offset: number; state: string; summary: string }> = [];
3031
+ for (const session of sessions) {
3032
+ const meta = readMeta(session.id);
3033
+ if (!shouldDeliverSubagentCompletion(meta)) continue;
3034
+ let progress: Progress;
3035
+ try {
3036
+ progress = taskProgressOf(session.id).progress;
3037
+ } catch {
3038
+ progress = emptyProgress();
3039
+ }
3040
+ if (session.state === "running" && !progress.done) continue;
3041
+
3042
+ const offset = meta.promptOffset ?? 0;
3043
+ if (meta.subagentCompletionObservedOffset !== offset) {
3044
+ meta.subagentCompletionObservedOffset = offset;
3045
+ meta.subagentCompletionObservedAt = Date.now();
3046
+ writeMeta(session.id, meta);
3047
+ continue;
3048
+ }
3049
+ if (Date.now() - (meta.subagentCompletionObservedAt ?? 0) < POLL_MS) continue;
3050
+ const summary = progress.done
3051
+ ? `task settled; ${progress.turns} turns, ${progress.toolCallCount} tools, $${progress.cost.toFixed(4)}`
3052
+ : `worker ${session.state} with exit code ${session.exit_code ?? "?"}; partial usage $${progress.cost.toFixed(4)}`;
3053
+ ready.push({ id: session.id, offset, state: session.state, summary });
3054
+ }
3055
+ if (ready.length === 0 || shouldDeferCompletionNotification(ctx.isIdle())) return;
3056
+
3057
+ const deliverable = ready.filter(({ id, offset }) => {
3058
+ const meta = readMeta(id);
3059
+ return shouldDeliverSubagentCompletion(meta) && (meta.promptOffset ?? 0) === offset;
3060
+ });
3061
+ if (deliverable.length === 0) return;
3062
+ pi.sendMessage(
3063
+ {
3064
+ customType: "pi-babysit-subagent-ready",
3065
+ content:
3066
+ `${deliverable.length === 1 ? "A background subagent is" : `${deliverable.length} background subagents are`} ready to collect:\n` +
3067
+ deliverable.map(({ id, summary }) => `- ${id}: ${summary}`).join("\n") +
3068
+ "\nCall babysit_wait now to retrieve the answer and charge nested usage before finishing the parent task.",
3069
+ display: true,
3070
+ details: { subagents: deliverable.map(({ id, state }) => ({ id, state })) },
3071
+ },
3072
+ { triggerTurn: true, deliverAs: "steer" },
3073
+ );
3074
+ for (const { id, offset } of deliverable) {
3075
+ const meta = readMeta(id);
3076
+ if (!meta || meta.kind !== "subagent" || (meta.promptOffset ?? 0) !== offset) continue;
3077
+ meta.subagentNotifiedOffset = offset;
3078
+ writeMeta(id, meta);
3079
+ }
3080
+ }
3081
+
2967
3082
  const refreshWidget = async (ctx: ExtensionContext, snapshot?: BsSession[]) => {
2968
3083
  if (!ctx.hasUI) return;
2969
3084
  const active = (snapshot ?? (await listSessions()).sessions).filter(
@@ -3157,6 +3272,7 @@ export default function (pi: ExtensionAPI) {
3157
3272
  await enforceSubagentBudgets(snapshot);
3158
3273
  await Promise.all([
3159
3274
  notifyEndedProcesses(ctx, snapshot),
3275
+ notifySettledSubagents(ctx, snapshot),
3160
3276
  refreshWidget(ctx, snapshot),
3161
3277
  ]);
3162
3278
  pruneTerminalSessionCache(taskProgressCache, snapshot);
@@ -3228,12 +3344,13 @@ export default function (pi: ExtensionAPI) {
3228
3344
  "`returnPattern`/`returnLines` bound foreground output. Sessions support check, wait, send, and kill.",
3229
3345
  promptSnippet: "Run supervised commands or bounded pi subagents with context-safe logs",
3230
3346
  promptGuidelines: [
3231
- "Use babysit_run for shell commands and give meaningful sessions a stable name; bundle tiny related observations.",
3232
- "Use babysit_run foreground mode when the next step needs the result; use returnPattern/returnLines for noisy commands. Do not foreground unbounded servers.",
3347
+ "Use babysit_run for shell commands and give meaningful sessions a stable name; bundle tiny related read-only observations into one command.",
3348
+ "Use babysit_run foreground mode for one process or subagent whose result is needed now; never issue sibling foreground runs in parallel. For parallel checks, start background runs with continueAfterStart and collect them with one multi-session babysit_wait.",
3349
+ "Use returnPattern/returnLines for noisy commands. During edit/fix loops run targeted checks first and one full validation suite at the end instead of repeating every full gate.",
3233
3350
  "After a background process starts, stop the turn for its automatic notification; never poll or sleep. Use continueAfterStart only for specific non-polling work.",
3234
3351
  "Inspect large logs with a narrow babysit_check pattern and maxBytes rather than broad tails.",
3235
3352
  "Use retryOnWorkerDeath only once and only for idempotent commands; retries may duplicate side effects.",
3236
- "Delegate independent work with bounded babysit_run subagents; set at least one cost/turn/tool/token budget and keep making progress before babysit_wait.",
3353
+ "Delegate independent work with bounded babysit_run subagents. Prefer foreground for one result needed now; every background subagent must be collected with babysit_wait before the parent task finishes. Size budgets above the worker's initial context and expected tool count.",
3237
3354
  "Subagent recursion defaults to depth 1; only a top-level caller may explicitly raise maxDepth.",
3238
3355
  ],
3239
3356
  parameters: Type.Object({
@@ -3321,7 +3438,7 @@ export default function (pi: ExtensionAPI) {
3321
3438
  ),
3322
3439
  foreground: Type.Optional(
3323
3440
  Type.Boolean({
3324
- description: "Process: wait for exit and return the result now.",
3441
+ description: "Process or subagent: wait for completion and return the result in this tool call.",
3325
3442
  }),
3326
3443
  ),
3327
3444
  returnPattern: Type.Optional(
@@ -3396,16 +3513,16 @@ export default function (pi: ExtensionAPI) {
3396
3513
  details: {},
3397
3514
  };
3398
3515
  }
3399
- if (isSubagent && params.foreground) {
3516
+ if (isSubagent && (params.returnPattern || params.returnLines != null || params.maxBytes != null)) {
3400
3517
  return {
3401
- content: [{ type: "text", text: "`foreground` is available only in process mode; use babysit_wait for subagent task completion." }],
3518
+ content: [{ type: "text", text: "`returnPattern`, `returnLines`, and `maxBytes` are process-output options." }],
3402
3519
  isError: true,
3403
3520
  details: {},
3404
3521
  };
3405
3522
  }
3406
- if (isSubagent && (params.returnPattern || params.returnLines != null || params.maxBytes != null)) {
3523
+ if (isSubagent && params.continueAfterStart != null) {
3407
3524
  return {
3408
- content: [{ type: "text", text: "`returnPattern`, `returnLines`, and `maxBytes` are process-output options." }],
3525
+ content: [{ type: "text", text: "`continueAfterStart` is available only in process mode." }],
3409
3526
  isError: true,
3410
3527
  details: {},
3411
3528
  };
@@ -3625,15 +3742,37 @@ export default function (pi: ExtensionAPI) {
3625
3742
 
3626
3743
  pollNeeded = true;
3627
3744
  await refreshWidget(ctx);
3745
+ if (params.foreground || !ctx.hasUI) {
3746
+ const outcome = await waitForTask(res.id, null, _signal);
3747
+ collectSubagentOutcome(outcome);
3748
+ const usage = claimOutcomeUsage(outcome);
3749
+ if (ctx.hasUI) await refreshWidget(ctx);
3750
+ return {
3751
+ content: [{ type: "text", text: outcome.text }],
3752
+ isError: !outcome.ok,
3753
+ usage,
3754
+ details: {
3755
+ id: res.id,
3756
+ kind: "subagent",
3757
+ name: params.name ?? res.id,
3758
+ agent: agent?.name,
3759
+ model: res.model,
3760
+ task: params.task,
3761
+ depth: subagentNesting.childDepth,
3762
+ maxDepth: subagentNesting.maxDepth,
3763
+ status: outcomeStatus(outcome),
3764
+ },
3765
+ };
3766
+ }
3628
3767
  return {
3629
3768
  content: [
3630
3769
  {
3631
3770
  type: "text",
3632
3771
  text:
3633
3772
  `Subagent started (id: ${res.id})${agent ? ` [agent: ${agent.name}]` : ""}${res.model ? ` [model: ${res.model}]` : ""} [depth: ${subagentNesting.childDepth}/${subagentNesting.maxDepth}].\n` +
3634
- `Task accepted — running in the background; keep working (do NOT end your turn just to wait for it).\n` +
3635
- `Poll: babysit_check { id: "${res.id}" }\n` +
3636
- `Wait: babysit_wait { id: "${res.id}" }\n` +
3773
+ `Task accepted — running in the background. You MUST collect it with babysit_wait before finishing the parent task; use foreground: true next time when no independent work is available.\n` +
3774
+ `Progress: babysit_check { id: "${res.id}" } (only when inspection is needed)\n` +
3775
+ `Collect: babysit_wait { id: "${res.id}" }\n` +
3637
3776
  `Human can watch/steer: /babysit (pick ${res.id})`,
3638
3777
  },
3639
3778
  ],
@@ -4257,6 +4396,7 @@ export default function (pi: ExtensionAPI) {
4257
4396
 
4258
4397
  if (ids.length === 1) {
4259
4398
  const r = await waitFor(ids[0], limitMs, signal, params.expect);
4399
+ collectSubagentOutcome(r);
4260
4400
  const usage = claimOutcomeUsage(r);
4261
4401
  return {
4262
4402
  content: [{ type: "text", text: r.text }],
@@ -4277,6 +4417,7 @@ export default function (pi: ExtensionAPI) {
4277
4417
  ids.map((i) => waitFor(i, limitMs, signal, params.expect)),
4278
4418
  );
4279
4419
  const ok = results.every((r) => r.ok);
4420
+ results.forEach(collectSubagentOutcome);
4280
4421
  const usage = sumNestedUsage(results.map(claimOutcomeUsage));
4281
4422
  return {
4282
4423
  content: [
@@ -4306,6 +4447,7 @@ export default function (pi: ExtensionAPI) {
4306
4447
  ids.map((i) => waitFor(i, limitMs, ctrl.signal, params.expect)),
4307
4448
  );
4308
4449
  const others = ids.filter((i) => i !== first.id);
4450
+ collectSubagentOutcome(first);
4309
4451
  const usage = claimOutcomeUsage(first);
4310
4452
  return {
4311
4453
  content: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
5
5
  "keywords": [
6
6
  "pi-package",