@yusukeshib/pi-babysit 0.3.14 → 0.3.16

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,8 +56,8 @@ 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`) or start a subagent (`profile: "subagent"`, `task`, optional `agent`/`model`/`tools`/`maxDepth` and `maxCost`/`maxTurns`/`maxToolCalls`/`maxUsageTokens` budgets). `maxDepth` defaults to 1 and can only be set by the top-level caller. Quick commands return inline; longer ones continue in the background |
60
- | `babysit_check` | List all sessions, inspect one, tail its bounded recent output, or search its raw log with `pattern`; `screen: true` captures TUIs and subagents otherwise show structured live progress |
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, avoiding a separate `babysit_wait` turn. Or start a named subagent (`profile: "subagent"`, `task`, optional `name`/`agent`/`model`/`tools`/`maxDepth` and `maxCost`/`maxTurns`/`maxToolCalls`/`maxUsageTokens` budgets). `maxDepth` defaults to 1 and can only be set by the top-level caller. Quick commands return inline; longer ones continue in the background |
60
+ | `babysit_check` | Without an id, list running sessions by default (`state: "all"` includes history; `state`/`kind` filters are available). With an id, inspect one session, tail bounded recent output, or search its raw log with `pattern`; `screen: true` captures TUIs and subagents otherwise show structured live progress |
61
61
  | `babysit_send` | Process: type `text` / press `keys` into the PTY. Subagent: steer mid-run, or send a follow-up task when idle (`mode: auto/steer/task`) |
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"` |
63
63
  | `babysit_kill` | Terminate a session, verify terminal state, then suppress the exit notification |
@@ -106,13 +106,13 @@ a raw RPC JSON tail.
106
106
 
107
107
  Subagent logs compact Pi's streaming `message_update` events before they are
108
108
  recorded. Pi repeats the complete growing assistant message and partial snapshot
109
- on every token; pi-babysit retains only the incremental delta. The opt-in
110
- `compact` RPC log mode also removes duplicate payloads from `message_start`,
109
+ on every token; pi-babysit retains only the incremental delta. Compact RPC
110
+ logging is the default and also removes duplicate payloads from `message_start`,
111
111
  `turn_end`, successful `tool_execution_end`, and `agent_end`, while preserving
112
112
  authoritative `message_end`, failures, responses, and errors. Parked-process
113
113
  state is materialized as a small boolean so completion detection is unchanged.
114
- Set `PI_BABYSIT_RPC_LOG_MODE=standard` for the legacy lifecycle payloads. Live
115
- `/babysit` and attach views render either format.
114
+ Set `PI_BABYSIT_RPC_LOG_MODE=standard` only when legacy lifecycle payloads are
115
+ needed for debugging. Live `/babysit` and attach views render either format.
116
116
 
117
117
  All shell commands, including `pwd` and Git, go through `babysit_run`. Bundle
118
118
  closely related tiny observations when doing so safely reduces tool turns.
@@ -134,9 +134,11 @@ because blindly rerunning an arbitrary command can duplicate side effects.
134
134
  `pi.sendMessage(…, { triggerTurn: true, deliverAs: "steer" })` containing every
135
135
  deliverable exit observed in that poll (deduped via `meta/<id>.json`). Waiting
136
136
  for idleness prevents an immediately-following `babysit_wait` from racing the
137
- poller and receiving a duplicate completion. Processes with the same
138
- `notificationGroup` wait for every currently running group member to stop and
139
- then share one notification even when their exits span multiple polls.
137
+ poller and receiving a duplicate completion. Background process calls emitted
138
+ together in one assistant message automatically share a notification group;
139
+ an explicit `notificationGroup` overrides this. Group members wait for every
140
+ currently running member to stop and then share one notification even when
141
+ their exits span multiple polls.
140
142
  `babysit_kill` and an exit already reported by `babysit_wait` suppress the
141
143
  notification.
142
144
  - **Subagent**: `babysit_wait` blocks on `babysit expect '"type":"agent_settled"'`.
@@ -152,8 +154,10 @@ because blindly rerunning an arbitrary command can duplicate side effects.
152
154
 
153
155
  Subagents load `self-reap.ts`, which exits an idle finished subagent after a
154
156
  grace window (`PI_BABYSIT_REAP_AFTER`, default 120s) using the same parked-turn
155
- rule, so a subagent waiting on a long build is never false-killed. Optional task
156
- budgets are checked by the parent poller. On the first exceeded limit the worker
157
+ rule, so a subagent waiting on a long build is never false-killed. Give bounded
158
+ recon/review tasks at least one cost, turn, tool-call, or token budget; omit
159
+ budgets only for intentionally open-ended work. Optional task budgets are
160
+ checked by the parent poller. On the first exceeded limit the worker
157
161
  is steered to stop using tools and return its best answer; if it remains active
158
162
  after `PI_BABYSIT_BUDGET_GRACE`, termination is verified before the task is
159
163
  marked budget-killed. Usage shown by check/wait is cumulative for the task.
@@ -166,9 +170,10 @@ marked budget-killed. Usage shown by check/wait is cumulative for the task.
166
170
  | `PI_BABYSIT_BIN` | `pi` | agent binary for subagents |
167
171
  | `PI_BABYSIT_CLI` | `babysit` | babysit binary |
168
172
  | `PI_BABYSIT_VIEW_CMD` | bundled `format-stream.mjs` | live-attach pretty printer for subagent JSONL (`""` disables) |
173
+ | `PI_BABYSIT_QUICK_GRACE` | `2s` | interactive process grace before a still-running command is returned as background work; use `foreground: true` to wait explicitly |
169
174
  | `PI_BABYSIT_REAP_AFTER` | `120s` | idle grace before a finished subagent self-exits (`off`/`none`/`0` disables) |
170
175
  | `PI_BABYSIT_BUDGET_GRACE` | `30s` | grace after a subagent budget is exceeded before verified termination |
171
- | `PI_BABYSIT_RPC_LOG_MODE` | `standard` | `compact` removes duplicate RPC lifecycle payloads; `standard` retains legacy payloads |
176
+ | `PI_BABYSIT_RPC_LOG_MODE` | `compact` | `compact` removes duplicate RPC lifecycle payloads; `standard` opts into legacy payloads |
172
177
  | `PI_BABYSIT_RETENTION_DAYS` | unset | when set to a positive number, remove safe terminal roots older than this at session startup |
173
178
  | `PI_BABYSIT_TAIL_MAX_BYTES` | `8000` | cap for explicit log tails/screens returned by `babysit_check` |
174
179
  | `PI_BABYSIT_INLINE_OUTPUT_MAX_BYTES` | `8000` | cap for complete process output and aggregate multi-wait results |
package/index.ts CHANGED
@@ -148,16 +148,32 @@ const VIEW_CMD =
148
148
  // Appended to every subagent's system prompt. The subagent is a long-lived
149
149
  // headless `pi --mode rpc` worker: turns can end and resume, so babysit_run
150
150
  // (process kind) works normally inside it. It just cannot talk to a human.
151
- const SUBAGENT_GUIDANCE = [
152
- "You are a headless background worker driven over pi's RPC protocol.",
153
- "Work autonomously: you cannot ask the user questions, so state assumptions",
154
- "in your final answer instead. Long commands may be run synchronously via",
155
- "bash (blocking is fine) or via babysit_run (it works here). When your",
156
- "task is complete, produce a final answer message summarizing the outcome —",
157
- "your controller reads it from the event stream.",
158
- ].join(" ");
151
+ export function subagentGuidance(
152
+ depth: number,
153
+ maxDepth: number,
154
+ directBashAvailable: boolean,
155
+ babysitRunAvailable = true,
156
+ ): string {
157
+ const shellGuidance = directBashAvailable
158
+ ? "Direct bash is available, and babysit_run can supervise longer commands."
159
+ : babysitRunAvailable
160
+ ? "Direct bash is unavailable; run shell commands with babysit_run { command }."
161
+ : "No shell execution tool is available in this task's tool allowlist; do not attempt shell commands.";
162
+ const nestingGuidance = depth >= maxDepth
163
+ ? `You are at the inherited subagent depth limit (${depth}/${maxDepth}); do not attempt to spawn another subagent.`
164
+ : `Your inherited subagent depth is ${depth}/${maxDepth}; child subagents may not exceed maxDepth ${maxDepth}.`;
165
+ return [
166
+ "You are a headless background worker driven over pi's RPC protocol.",
167
+ "Work autonomously: you cannot ask the user questions, so state assumptions",
168
+ "in your final answer instead.",
169
+ shellGuidance,
170
+ nestingGuidance,
171
+ "When your task is complete, produce a final answer message summarizing the outcome —",
172
+ "your controller reads it from the event stream.",
173
+ ].join(" ");
174
+ }
159
175
  const POLL_MS = 2500;
160
- const QUICK_COMMAND_GRACE = process.env.PI_BABYSIT_QUICK_GRACE ?? "1s";
176
+ const QUICK_COMMAND_GRACE = process.env.PI_BABYSIT_QUICK_GRACE ?? "2s";
161
177
  const KILL_CONFIRM_TIMEOUT = "4s";
162
178
 
163
179
  interface BsSession {
@@ -1269,6 +1285,8 @@ export interface Progress {
1269
1285
  turns: number;
1270
1286
  toolCalls: ToolCall[];
1271
1287
  finalText: string;
1288
+ /** Best-effort text from the currently streaming assistant message. */
1289
+ streamingText: string;
1272
1290
  /** Context size reported by the most recent assistant response. */
1273
1291
  tokens?: number;
1274
1292
  /** Cumulative model usage for the current subagent task. */
@@ -1323,6 +1341,7 @@ function emptyProgress(): Progress {
1323
1341
  turns: 0,
1324
1342
  toolCalls: [],
1325
1343
  finalText: "",
1344
+ streamingText: "",
1326
1345
  modelCalls: 0,
1327
1346
  usageTokens: 0,
1328
1347
  inputTokens: 0,
@@ -1401,7 +1420,17 @@ function parseEventLine(progress: Progress, raw: string): void {
1401
1420
  switch (event.type) {
1402
1421
  case "turn_start":
1403
1422
  progress.turns++;
1423
+ progress.streamingText = "";
1424
+ break;
1425
+ case "message_update": {
1426
+ const update = event.assistantMessageEvent as
1427
+ | { type?: string; delta?: string }
1428
+ | undefined;
1429
+ if (update?.type === "text_delta" && typeof update.delta === "string") {
1430
+ progress.streamingText += update.delta;
1431
+ }
1404
1432
  break;
1433
+ }
1405
1434
  case "tool_execution_start": {
1406
1435
  const name = String(event.toolName ?? "tool");
1407
1436
  progress.toolCalls.push({
@@ -1432,6 +1461,7 @@ function parseEventLine(progress: Progress, raw: string): void {
1432
1461
  .map((content) => content.text)
1433
1462
  .join("");
1434
1463
  if (text.trim()) progress.finalText = text;
1464
+ progress.streamingText = "";
1435
1465
  if (message.usage) {
1436
1466
  const finite = (value: number | undefined) =>
1437
1467
  typeof value === "number" && Number.isFinite(value) ? value : 0;
@@ -1515,6 +1545,7 @@ export function buildSubagentExitDiagnostic(
1515
1545
  ): string {
1516
1546
  const body = clip(
1517
1547
  progress.errorMsg ||
1548
+ progress.streamingText.trim() ||
1518
1549
  progress.finalText.trim() ||
1519
1550
  "(no structured error was emitted; inspect the full log)",
1520
1551
  ANSWER_MAX_BYTES,
@@ -1587,13 +1618,16 @@ function taskProgressOf(id: string): { progress: Progress; offset: number } {
1587
1618
  // ---------------------------------------------------------------------------
1588
1619
 
1589
1620
  // Friendly name → unique babysit session id (babysit ids allow [\w.-]).
1590
- async function uniqueSessionId(name: string): Promise<string> {
1621
+ const reservedSessionIds = new Set<string>();
1622
+
1623
+ async function reserveUniqueSessionId(name: string): Promise<string> {
1591
1624
  const base = name.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "") || "proc";
1592
1625
  const taken = new Set((await listSessions()).sessions.map((s) => s.id));
1593
- if (!taken.has(base)) return base;
1594
- for (let i = 2; ; i++) {
1595
- if (!taken.has(`${base}-${i}`)) return `${base}-${i}`;
1596
- }
1626
+ for (const id of reservedSessionIds) taken.add(id);
1627
+ let id = base;
1628
+ for (let i = 2; taken.has(id); i++) id = `${base}-${i}`;
1629
+ reservedSessionIds.add(id);
1630
+ return id;
1597
1631
  }
1598
1632
 
1599
1633
  interface ProcOpts {
@@ -1612,10 +1646,16 @@ async function spawnProcess(opts: ProcOpts): Promise<{ id: string } | { error: s
1612
1646
  if (opts.timeout && opts.timeout !== "none") bsArgs.push("--timeout", opts.timeout);
1613
1647
  if (opts.idleTimeout && opts.idleTimeout !== "none")
1614
1648
  bsArgs.push("--idle-timeout", opts.idleTimeout);
1615
- if (opts.name) bsArgs.push("--id", await uniqueSessionId(opts.name));
1649
+ const reservedId = opts.name ? await reserveUniqueSessionId(opts.name) : undefined;
1650
+ if (reservedId) bsArgs.push("--id", reservedId);
1616
1651
  bsArgs.push("--", SHELL, "-c", opts.command);
1617
1652
 
1618
- const r = await bs(bsArgs, { cwd: opts.cwd });
1653
+ let r: Awaited<ReturnType<typeof bs>>;
1654
+ try {
1655
+ r = await bs(bsArgs, { cwd: opts.cwd });
1656
+ } finally {
1657
+ if (reservedId) reservedSessionIds.delete(reservedId);
1658
+ }
1619
1659
  if (r.code !== 0) {
1620
1660
  return {
1621
1661
  error:
@@ -1687,6 +1727,7 @@ function discardDelivery(delivery: DeliverableMessage): void {
1687
1727
  }
1688
1728
 
1689
1729
  interface SubagentOpts {
1730
+ name?: string;
1690
1731
  agent?: AgentConfig;
1691
1732
  task: string;
1692
1733
  model?: string;
@@ -1720,7 +1761,15 @@ async function spawnSubagent(
1720
1761
  };
1721
1762
  }
1722
1763
  if (tools && tools.length > 0) piArgs.push("--tools", tools.join(","));
1723
- piArgs.push("--append-system-prompt", SUBAGENT_GUIDANCE);
1764
+ piArgs.push(
1765
+ "--append-system-prompt",
1766
+ subagentGuidance(
1767
+ opts.depth,
1768
+ opts.maxDepth,
1769
+ isAllowedDirectBash("") && (!tools || tools.includes("bash")),
1770
+ !tools || tools.includes("babysit_run"),
1771
+ ),
1772
+ );
1724
1773
  let promptTempFile: string | undefined;
1725
1774
  if (opts.agent?.systemPrompt?.trim()) {
1726
1775
  promptTempFile = writePromptTempFile(opts.agent.name, opts.agent.systemPrompt);
@@ -1758,6 +1807,8 @@ async function spawnSubagent(
1758
1807
  if (opts.idleTimeout && opts.idleTimeout !== "none") {
1759
1808
  bsArgs.push("--idle-timeout", opts.idleTimeout);
1760
1809
  }
1810
+ const reservedId = opts.name ? await reserveUniqueSessionId(opts.name) : undefined;
1811
+ if (reservedId) bsArgs.push("--id", reservedId);
1761
1812
  bsArgs.push(
1762
1813
  "--",
1763
1814
  process.execPath,
@@ -1767,13 +1818,18 @@ async function spawnSubagent(
1767
1818
  ...piArgs,
1768
1819
  );
1769
1820
 
1770
- const r = await bs(bsArgs, {
1771
- cwd: opts.cwd,
1772
- env: {
1773
- [SUBAGENT_DEPTH_ENV]: String(opts.depth),
1774
- [SUBAGENT_MAX_DEPTH_ENV]: String(opts.maxDepth),
1775
- },
1776
- });
1821
+ let r: Awaited<ReturnType<typeof bs>>;
1822
+ try {
1823
+ r = await bs(bsArgs, {
1824
+ cwd: opts.cwd,
1825
+ env: {
1826
+ [SUBAGENT_DEPTH_ENV]: String(opts.depth),
1827
+ [SUBAGENT_MAX_DEPTH_ENV]: String(opts.maxDepth),
1828
+ },
1829
+ });
1830
+ } finally {
1831
+ if (reservedId) reservedSessionIds.delete(reservedId);
1832
+ }
1777
1833
  if (r.code !== 0) {
1778
1834
  cleanupPromptTemp();
1779
1835
  return { error: r.stderr || r.stdout || `babysit run failed (exit ${r.code}, no output) — check that \`${BABYSIT_BIN}\` works and ${ROOT} is writable` };
@@ -1792,6 +1848,7 @@ async function spawnSubagent(
1792
1848
  // The success path overwrites this with the full task meta.
1793
1849
  writeMeta(id, {
1794
1850
  kind: "subagent",
1851
+ name: opts.name ?? id,
1795
1852
  task: opts.task,
1796
1853
  notified: true,
1797
1854
  depth: opts.depth,
@@ -1851,6 +1908,7 @@ async function spawnSubagent(
1851
1908
 
1852
1909
  writeMeta(id, {
1853
1910
  kind: "subagent",
1911
+ name: opts.name ?? id,
1854
1912
  task: opts.task,
1855
1913
  promptOffset: resp.offset,
1856
1914
  model: resolvedModel,
@@ -2136,9 +2194,15 @@ async function waitForExit(
2136
2194
 
2137
2195
  if (expectPattern) {
2138
2196
  const e = await bs(["expect", "-s", id, "--timeout", t, expectPattern], { signal });
2139
- if (signal?.aborted || e.code === 130) {
2197
+ if (signal?.aborted) {
2140
2198
  return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
2141
2199
  }
2200
+ if (e.code === 130) {
2201
+ const interruptedStatus = await statusOf(id);
2202
+ if (interruptedStatus?.state === "running") {
2203
+ return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
2204
+ }
2205
+ }
2142
2206
  if (e.code === 0) {
2143
2207
  return {
2144
2208
  id,
@@ -2164,10 +2228,17 @@ async function waitForExit(
2164
2228
  // another wait that is still pending.
2165
2229
  updateWaitReservation(id, "reserve");
2166
2230
  const w = await bs(["wait", "-s", id, "--timeout", t], { signal });
2167
- if (signal?.aborted || w.code === 130) {
2231
+ if (signal?.aborted) {
2168
2232
  updateWaitReservation(id, "abandon");
2169
2233
  return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
2170
2234
  }
2235
+ if (w.code === 130) {
2236
+ const interruptedStatus = await statusOf(id);
2237
+ if (interruptedStatus?.state === "running") {
2238
+ updateWaitReservation(id, "abandon");
2239
+ return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
2240
+ }
2241
+ }
2171
2242
  if (w.code === 124) {
2172
2243
  // 124 is ambiguous (timeout vs child exiting 124) — disambiguate.
2173
2244
  const st0 = await statusOf(id);
@@ -2258,6 +2329,44 @@ export function activeToolsWithoutDirectBash(activeTools: string[], allowDirectB
2258
2329
  return allowDirectBash ? activeTools : activeTools.filter((name) => name !== "bash");
2259
2330
  }
2260
2331
 
2332
+ export function automaticNotificationGroup(entry: unknown): string | undefined {
2333
+ const candidate = entry as {
2334
+ id?: unknown;
2335
+ type?: unknown;
2336
+ message?: { role?: unknown; content?: unknown };
2337
+ };
2338
+ if (
2339
+ candidate?.type !== "message" ||
2340
+ candidate.message?.role !== "assistant" ||
2341
+ typeof candidate.id !== "string" ||
2342
+ !Array.isArray(candidate.message.content)
2343
+ ) {
2344
+ return undefined;
2345
+ }
2346
+ const group = `turn-${candidate.id}`;
2347
+ const runs = candidate.message.content.filter((part) => {
2348
+ if (!part || typeof part !== "object") return false;
2349
+ const call = part as {
2350
+ type?: unknown;
2351
+ name?: unknown;
2352
+ arguments?: Record<string, unknown>;
2353
+ };
2354
+ if (call.type !== "toolCall" || call.name !== "babysit_run") return false;
2355
+ const args = call.arguments ?? {};
2356
+ const existing = typeof args.notificationGroup === "string"
2357
+ ? args.notificationGroup.trim()
2358
+ : "";
2359
+ return (
2360
+ typeof args.command === "string" &&
2361
+ args.command.length > 0 &&
2362
+ args.profile !== "subagent" &&
2363
+ args.foreground !== true &&
2364
+ (existing === "" || existing === group)
2365
+ );
2366
+ });
2367
+ return runs.length >= 2 ? group : undefined;
2368
+ }
2369
+
2261
2370
  // ---------------------------------------------------------------------------
2262
2371
  // extension
2263
2372
  // ---------------------------------------------------------------------------
@@ -2431,7 +2540,7 @@ export default function (pi: ExtensionAPI) {
2431
2540
  : "failed";
2432
2541
  const output = await inlineOutput(session.id, session, NOTIFY_OUTPUT_MAX_BYTES);
2433
2542
  const runtime = meta.startedAt
2434
- ? `${Math.round((Date.now() - meta.startedAt) / 1000)}s`
2543
+ ? `${Math.round(((meta.completionObservedAt ?? Date.now()) - meta.startedAt) / 1000)}s`
2435
2544
  : "?";
2436
2545
  const summary = ok
2437
2546
  ? `Process "${session.id}" completed successfully after ${runtime}.`
@@ -2451,6 +2560,8 @@ export default function (pi: ExtensionAPI) {
2451
2560
  });
2452
2561
  }
2453
2562
 
2563
+ if (prepared.length === 0) return;
2564
+
2454
2565
  // Output loading above is asynchronous. Refresh sessions and metadata
2455
2566
  // immediately before the single send so concurrent wait/kill calls and newly
2456
2567
  // started notification-group members are honored.
@@ -2688,7 +2799,25 @@ export default function (pi: ExtensionAPI) {
2688
2799
  rootLeasePath = undefined;
2689
2800
  });
2690
2801
 
2691
- pi.on("tool_call", async (event) => {
2802
+ pi.on("tool_call", async (event, ctx) => {
2803
+ if (event.toolName === "babysit_run") {
2804
+ const input = event.input as {
2805
+ command?: unknown;
2806
+ profile?: unknown;
2807
+ foreground?: unknown;
2808
+ notificationGroup?: unknown;
2809
+ };
2810
+ if (
2811
+ typeof input.command === "string" &&
2812
+ input.profile !== "subagent" &&
2813
+ input.foreground !== true &&
2814
+ (typeof input.notificationGroup !== "string" || input.notificationGroup.trim() === "")
2815
+ ) {
2816
+ const group = automaticNotificationGroup(ctx.sessionManager.getLeafEntry());
2817
+ if (group) input.notificationGroup = group;
2818
+ }
2819
+ return;
2820
+ }
2692
2821
  if (event.toolName !== "bash") return;
2693
2822
  const command = String((event.input as { command?: unknown }).command ?? "");
2694
2823
  if (backgroundsItself(command)) {
@@ -2714,10 +2843,12 @@ export default function (pi: ExtensionAPI) {
2714
2843
  name: "babysit_run",
2715
2844
  label: "Babysit: run",
2716
2845
  description:
2717
- "Run any shell command in a supervised babysit session. Commands that finish within a short " +
2718
- "grace period return completion metadata immediately; longer commands continue in the background " +
2719
- "and trigger an automatic notification on exit. Complete output is returned inline only when it is " +
2720
- "small; larger output stays in the log path for bounded inspection with babysit_check. " +
2846
+ "Run any shell command in a supervised babysit session. Set `foreground: true` when the next step " +
2847
+ "needs the exit result in this tool call. Otherwise commands that finish within a short grace period " +
2848
+ "return completion metadata immediately; longer commands continue in the background and trigger an " +
2849
+ "automatic notification on exit. Sibling background runs in one assistant message are grouped " +
2850
+ "automatically. Complete output is returned inline only when it is small; larger output stays " +
2851
+ "in the log path for bounded inspection with babysit_check. " +
2721
2852
  "In non-interactive mode (`pi -p`, no UI), process mode blocks until exit because there is no " +
2722
2853
  "notification loop. Two modes: (1) `command` — run any shell command, including builds, tests, " +
2723
2854
  "dev servers, watchers, and interactive TUIs; you can type into it with babysit_send and read " +
@@ -2730,13 +2861,15 @@ export default function (pi: ExtensionAPI) {
2730
2861
  promptSnippet:
2731
2862
  "Run any shell command with context-safe captured output; quick commands return metadata, longer ones continue in background",
2732
2863
  promptGuidelines: [
2733
- "Use babysit_run as the default for shell commands, not only long-running work. Small output is returned directly; large stdout/stderr stays out of model context in the returned log path. Give meaningful commands a clear stable `name`.",
2864
+ "Use babysit_run as the default for shell commands, not only long-running work. Small output is returned directly; large stdout/stderr stays out of model context in the returned log path. Give every meaningful process or subagent a clear stable `name`.",
2865
+ "Use babysit_run { command, foreground: true } when the result is required before the next step; this avoids a separate babysit_wait model turn. Do not use foreground for servers, watchers, or commands of unknown duration without a timeout.",
2734
2866
  "Bundle closely related tiny observations into one babysit_run command when that reduces tool turns without obscuring lifecycle or failure handling.",
2735
2867
  "Inspect a babysit log with babysit_check { id, lines, pattern? }; never read or cat a potentially large log file in full. Prefer a targeted `pattern` search over returning a broad tail.",
2736
2868
  "After babysit_run { command } starts a process, end your response immediately so the automatic process-end notification can resume you; NEVER poll with babysit_check or sleep. Set continueAfterStart: true only when you have immediate, specific, non-polling work to do next. Call babysit_wait when you must consume the result inside the current turn (optionally with `expect` to wait for a readiness line like 'listening on').",
2737
2869
  "If a babysit worker is killed externally, babysit_run reports it as worker-dead rather than hanging. Set retryOnWorkerDeath: true only for safe, idempotent commands; it retries at most once and may otherwise duplicate side effects.",
2738
2870
  "babysit_run gives full PTY control: drive interactive programs (installers, wizards, REPLs) with babysit_send (text or named keys) and read the rendered screen with babysit_check { screen: true }.",
2739
2871
  "Delegate self-contained tasks (codebase recon, a parallelizable subtask, work that would pollute your context) with babysit_run { profile: \"subagent\", task }. Launch several for independent subtasks; they run concurrently.",
2872
+ "Set at least one subagent budget (`maxCost`, `maxTurns`, `maxToolCalls`, or `maxUsageTokens`) for bounded recon and review tasks. Omit budgets only for intentionally open-ended work; the absolute timeout remains a separate safety limit.",
2740
2873
  "Subagents cannot create further subagents by default (maximum depth 1). Only the top-level caller can explicitly opt in by setting maxDepth when it creates the first worker; nested workers inherit that limit and cannot raise it.",
2741
2874
  "After spawning subagents, do not idle-wait and do not end your turn to wait for them: keep making progress, then call babysit_wait (ids + mode any/all) when you need their results. Steer or send follow-up tasks with babysit_send; kill runaways with babysit_kill.",
2742
2875
  ],
@@ -2748,7 +2881,7 @@ export default function (pi: ExtensionAPI) {
2748
2881
  ),
2749
2882
  name: Type.Optional(
2750
2883
  Type.String({
2751
- description: "Friendly stable name for a process (becomes the session id), e.g. 'cargo-build'.",
2884
+ description: "Friendly stable name for a process or subagent (becomes the session id), e.g. 'cargo-build' or 'review-api'.",
2752
2885
  }),
2753
2886
  ),
2754
2887
  profile: Type.Optional(
@@ -2814,10 +2947,16 @@ export default function (pi: ExtensionAPI) {
2814
2947
  "Process mode: run in a PTY (default true; enables interactive input/screen). false = plain pipes for cleaner line-oriented logs.",
2815
2948
  }),
2816
2949
  ),
2950
+ foreground: Type.Optional(
2951
+ Type.Boolean({
2952
+ description:
2953
+ "Process mode: wait for exit and return the result in this tool call. Use when the next step needs the result; avoid for servers/watchers unless bounded by timeout.",
2954
+ }),
2955
+ ),
2817
2956
  notificationGroup: Type.Optional(
2818
2957
  Type.String({
2819
2958
  description:
2820
- "Process mode: defer automatic completion until every running process with this group has stopped, then send one batched notification.",
2959
+ "Process mode: defer automatic completion until every running process with this group has stopped, then send one batched notification. Sibling background runs are auto-grouped when omitted.",
2821
2960
  }),
2822
2961
  ),
2823
2962
  continueAfterStart: Type.Optional(
@@ -2877,6 +3016,20 @@ export default function (pi: ExtensionAPI) {
2877
3016
  details: {},
2878
3017
  };
2879
3018
  }
3019
+ if (isSubagent && params.foreground) {
3020
+ return {
3021
+ content: [{ type: "text", text: "`foreground` is available only in process mode; use babysit_wait for subagent task completion." }],
3022
+ isError: true,
3023
+ details: {},
3024
+ };
3025
+ }
3026
+ if (!isSubagent && params.foreground && params.continueAfterStart) {
3027
+ return {
3028
+ content: [{ type: "text", text: "`foreground` and `continueAfterStart` are mutually exclusive." }],
3029
+ isError: true,
3030
+ details: {},
3031
+ };
3032
+ }
2880
3033
 
2881
3034
  // Compute nesting only for subagent mode. Ordinary command processes remain
2882
3035
  // available even when the hosting agent is at its subagent depth limit.
@@ -2913,24 +3066,27 @@ export default function (pi: ExtensionAPI) {
2913
3066
  pollNeeded = true;
2914
3067
  await refreshWidget(ctx);
2915
3068
 
2916
- // One-shot / non-interactive mode (e.g. `pi -p`): there is no live
2917
- // event loop to deliver the exit notification, and ending the turn
2918
- // would exit pi entirely — orphaning the babysat process and losing
2919
- // the session (the agent "settles" the moment it is told the turn will
2920
- // stop). So block inline like a normal command and return the full
2921
- // outcome in THIS turn. The process still runs under babysit (logged,
2922
- // killable), we just wait for it here instead of fire-and-forget.
2923
- if (!ctx.hasUI) {
2924
- let outcome = await waitForExit(res.id, parseDurMs(params.timeout), _signal);
3069
+ // One-shot / non-interactive mode has no event loop that can deliver an
3070
+ // exit notification. `foreground: true` provides the same single-tool-call
3071
+ // result in interactive mode, avoiding a separate babysit_wait model turn.
3072
+ // The command remains supervised, logged, killable, and subject to its
3073
+ // babysit timeout in either case.
3074
+ if (!ctx.hasUI || params.foreground) {
3075
+ // The babysit supervisor owns the absolute command timeout. Waiting with
3076
+ // the same deadline here races its terminal-state write and can return a
3077
+ // false "still running" result at the boundary, so wait for the
3078
+ // supervisor's definitive exit instead.
3079
+ let outcome = await waitForExit(res.id, null, _signal);
2925
3080
  let retried = false;
2926
3081
  if (params.retryOnWorkerDeath && outcome.status?.state === "dead" && outcome.status.exit_code == null) {
2927
3082
  const retry = await spawnProcess(spawnOpts);
2928
3083
  if (!("error" in retry)) {
2929
3084
  res = retry;
2930
3085
  retried = true;
2931
- outcome = await waitForExit(res.id, parseDurMs(params.timeout), _signal);
3086
+ outcome = await waitForExit(res.id, null, _signal);
2932
3087
  }
2933
3088
  }
3089
+ if (ctx.hasUI) await refreshWidget(ctx);
2934
3090
  return {
2935
3091
  content: [{ type: "text", text: `${retried ? "Retried once after external worker death.\n" : ""}${outcome.text}` }],
2936
3092
  isError: !outcome.ok,
@@ -2946,9 +3102,9 @@ export default function (pi: ExtensionAPI) {
2946
3102
  }
2947
3103
 
2948
3104
  // Keep ordinary quick commands ergonomic. Give the process a short grace
2949
- // period; if it exits, return only lifecycle metadata + log path now.
2950
- // A timeout means it is genuinely background work and follows the normal
2951
- // parked-turn / automatic-notification contract below.
3105
+ // period; if it exits, return lifecycle metadata + log path immediately.
3106
+ // A process still running after the grace follows the parked-turn /
3107
+ // automatic-notification contract below.
2952
3108
  await bs(["wait", "-s", res.id, "--timeout", QUICK_COMMAND_GRACE], { signal: _signal });
2953
3109
  let quickStatus = await statusOf(res.id);
2954
3110
  let retried = false;
@@ -3036,6 +3192,7 @@ export default function (pi: ExtensionAPI) {
3036
3192
  // The branch above guarantees a successful plan in subagent mode.
3037
3193
  const subagentNesting = nesting as Extract<SubagentSpawnPlan, { allowed: true }>;
3038
3194
  const res = await spawnSubagent({
3195
+ name: params.name,
3039
3196
  agent,
3040
3197
  task: params.task as string,
3041
3198
  model: params.model,
@@ -3081,6 +3238,7 @@ export default function (pi: ExtensionAPI) {
3081
3238
  details: {
3082
3239
  id: res.id,
3083
3240
  kind: "subagent",
3241
+ name: params.name ?? res.id,
3084
3242
  agent: agent?.name,
3085
3243
  model: res.model,
3086
3244
  task: params.task,
@@ -3140,14 +3298,25 @@ export default function (pi: ExtensionAPI) {
3140
3298
  name: "babysit_check",
3141
3299
  label: "Babysit: check",
3142
3300
  description:
3143
- "Inspect babysit session(s). Without an id: lists all sessions (processes + subagents). " +
3144
- "With an id: a process shows state + recent output, searches its log with `pattern`, " +
3301
+ "Inspect babysit session(s). Without an id: lists running sessions by default; use `state: \"all\"` " +
3302
+ "for history, or filter by terminal state/kind. With an id: a process shows state + recent " +
3303
+ "output, searches its log with `pattern`, " +
3145
3304
  "or captures the rendered screen with `screen: true`; a subagent shows live progress " +
3146
3305
  "(or raw log matches with `pattern`). Results are bounded by `lines` and clipped. " +
3147
3306
  "Do NOT poll this while merely waiting for a process to end — the exit notification is automatic.",
3148
3307
  promptSnippet: "Check status/progress of babysit sessions (processes and subagents)",
3149
3308
  parameters: Type.Object({
3150
- id: Type.Optional(Type.String({ description: "Session id. Omit to list all sessions." })),
3309
+ id: Type.Optional(Type.String({ description: "Session id. Omit to list sessions." })),
3310
+ state: Type.Optional(
3311
+ StringEnum(["running", "terminal", "all"] as const, {
3312
+ description: "List mode only: state filter. Defaults to running; terminal means any non-running state.",
3313
+ }),
3314
+ ),
3315
+ kind: Type.Optional(
3316
+ StringEnum(["process", "subagent", "all"] as const, {
3317
+ description: "List mode only: session kind filter. Defaults to all.",
3318
+ }),
3319
+ ),
3151
3320
  tools: Type.Optional(
3152
3321
  Type.Number({ description: "Subagent: how many recent tool calls to show (default 8, max 50)." }),
3153
3322
  ),
@@ -3179,9 +3348,38 @@ export default function (pi: ExtensionAPI) {
3179
3348
  };
3180
3349
  }
3181
3350
  if (sessions.length === 0) {
3182
- return { content: [{ type: "text", text: "No babysit sessions." }], details: {} };
3351
+ return { content: [{ type: "text", text: "No babysit sessions." }], details: { sessions: [] } };
3183
3352
  }
3184
- const lines = sessions.map((s) => {
3353
+ const stateFilter = params.state ?? "running";
3354
+ const kindFilter = params.kind ?? "all";
3355
+ const stateMatches = (session: BsSession) => stateFilter === "all"
3356
+ ? true
3357
+ : stateFilter === "running"
3358
+ ? session.state === "running"
3359
+ : session.state !== "running";
3360
+ const kindOfSession = (session: BsSession) => readMeta(session.id)?.kind ?? "process";
3361
+ const selected = sessions.filter((session) =>
3362
+ stateMatches(session) && (kindFilter === "all" || kindOfSession(session) === kindFilter),
3363
+ );
3364
+ const reveal: string[] = [];
3365
+ if (stateFilter !== "all" && sessions.some((session) => !stateMatches(session))) {
3366
+ reveal.push('state: "all"');
3367
+ }
3368
+ if (kindFilter !== "all" && sessions.some((session) => kindOfSession(session) !== kindFilter)) {
3369
+ reveal.push('kind: "all"');
3370
+ }
3371
+ const revealHint = reveal.length > 0 ? `; use ${reveal.join(" and ")} to widen the list` : "";
3372
+ if (selected.length === 0) {
3373
+ const hidden = sessions.length;
3374
+ return {
3375
+ content: [{
3376
+ type: "text",
3377
+ text: `No ${stateFilter}${kindFilter === "all" ? "" : ` ${kindFilter}`} babysit sessions.${hidden > 0 ? ` ${hidden} session(s) hidden${revealHint}.` : ""}`,
3378
+ }],
3379
+ details: { sessions: [], total: sessions.length, hidden, state: stateFilter, kind: kindFilter },
3380
+ };
3381
+ }
3382
+ const lines = selected.map((s) => {
3185
3383
  const meta = readMeta(s.id);
3186
3384
  const kind = meta?.kind ?? "process";
3187
3385
  const flag = s.note ? ` ⚑ ${s.note}` : "";
@@ -3194,7 +3392,12 @@ export default function (pi: ExtensionAPI) {
3194
3392
  const preview = what.length > 60 ? `${what.slice(0, 57)}…` : what;
3195
3393
  return `${s.id} [${kind}] ${s.state}${ec}${depth}${flag}${preview ? ` — ${preview}` : ""}`;
3196
3394
  });
3197
- return { content: [{ type: "text", text: clip(lines.join("\n")) }], details: { sessions } };
3395
+ const hidden = sessions.length - selected.length;
3396
+ const suffix = hidden > 0 ? `\n… ${hidden} session(s) hidden${revealHint}.` : "";
3397
+ return {
3398
+ content: [{ type: "text", text: clip(lines.join("\n") + suffix) }],
3399
+ details: { sessions: selected, total: sessions.length, hidden, state: stateFilter, kind: kindFilter },
3400
+ };
3198
3401
  }
3199
3402
 
3200
3403
  const st = await statusOf(params.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
4
4
  "description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -8,10 +8,10 @@
8
8
  * message. Persisting every snapshot makes babysit logs grow quadratically.
9
9
  *
10
10
  * This proxy forwards stdin byte-for-byte. `message_update` snapshots are
11
- * always compacted. In opt-in `compact` log mode, redundant payloads on
12
- * lifecycle/tool events are also removed while authoritative `message_end`,
11
+ * always compacted. Compact log mode is the default: redundant payloads on
12
+ * lifecycle/tool events are removed while authoritative `message_end`,
13
13
  * response, and error events remain intact. Set PI_BABYSIT_RPC_LOG_MODE=standard
14
- * to retain the legacy lifecycle payloads.
14
+ * to opt back into the legacy lifecycle payloads.
15
15
  */
16
16
  import { spawn } from "node:child_process";
17
17
  import { StringDecoder } from "node:string_decoder";
@@ -44,7 +44,7 @@ function isParkedMessages(messages) {
44
44
 
45
45
  export function compactRpcLine(
46
46
  line,
47
- mode = process.env.PI_BABYSIT_RPC_LOG_MODE ?? "standard",
47
+ mode = process.env.PI_BABYSIT_RPC_LOG_MODE ?? "compact",
48
48
  ) {
49
49
  if (!line.trimStart().startsWith("{")) return line;
50
50