@yusukeshib/pi-babysit 0.3.9 → 0.3.12

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
@@ -62,8 +62,11 @@ programs** (installers, wizards, REPLs): type with `babysit_send`
62
62
  | `babysit_wait` | Block until done: process exit (or `expect: "regex"` readiness marker), subagent task completion. Multi-wait: `ids` + `mode: "any"\|"all"` |
63
63
  | `babysit_kill` | Terminate a session, verify terminal state, then suppress the exit notification |
64
64
 
65
- A `tool_call` hook blocks shell backgrounding (`… &`, `nohup`, `setsid`,
66
- `disown`) and redirects all direct `bash` commands to `babysit_run`.
65
+ The built-in `bash` tool is removed from the active tool set so the model does
66
+ not waste a failed tool turn before choosing `babysit_run`. A fallback
67
+ `tool_call` hook still blocks direct shell calls if another extension or preset
68
+ re-enables `bash`, including shell backgrounding (`… &`, `nohup`, `setsid`,
69
+ `disown`). Set `PI_BABYSIT_ALLOW_BASH=1` to retain direct `bash` explicitly.
67
70
 
68
71
  ## Commands (human)
69
72
 
@@ -91,11 +94,21 @@ babysit_check { id: "cargo-test", lines: 50 }
91
94
  babysit_check { id: "cargo-test", pattern: "FAIL|ERROR", lines: 50 }
92
95
  ```
93
96
 
94
- Tail and search results are capped at 200 lines and clipped to 8 KB. Pattern
95
- search returns the latest matching lines with line numbers. Do not read a
96
- potentially large log file in full.
97
-
98
- All shell commands, including `pwd` and Git, are redirected to `babysit_run`.
97
+ Tail and search results are capped at 200 lines, and the complete returned tool
98
+ result (including lifecycle headers) is clipped to 8 KB. Pattern search returns
99
+ the latest matching lines with line numbers. Prefer a targeted pattern over a
100
+ broad tail, and do not read a potentially large log file in full.
101
+
102
+ Subagent logs also compact Pi's streaming `message_update` events before they
103
+ are recorded. Pi repeats the complete growing assistant message and partial
104
+ snapshot on every token; pi-babysit retains only the incremental delta while
105
+ leaving authoritative `message_end`, tool, lifecycle, response, and error
106
+ events untouched. This keeps long RPC sessions approximately linear in emitted
107
+ content without changing final answers, completion detection, or follow-up
108
+ behavior. Live `/babysit` and attach views render the retained deltas.
109
+
110
+ All shell commands, including `pwd` and Git, go through `babysit_run`. Bundle
111
+ closely related tiny observations when doing so safely reduces tool turns.
99
112
  Set `PI_BABYSIT_ALLOW_BASH=1` only as an explicit emergency escape hatch.
100
113
 
101
114
  ## Unexpected worker loss
@@ -109,9 +122,12 @@ because blindly rerunning an arbitrary command can duplicate side effects.
109
122
 
110
123
  ## How completion detection works
111
124
 
112
- - **Process**: a 2.5s poller watches for running→exited transitions and injects
113
- one `pi.sendMessage(…, { triggerTurn: true, deliverAs: "steer" })` containing
114
- every deliverable exit observed in that poll (deduped via `meta/<id>.json`).
125
+ - **Process**: a 2.5s poller watches for running→exited transitions and, once the
126
+ parent agent is idle, injects one
127
+ `pi.sendMessage(…, { triggerTurn: true, deliverAs: "steer" })` containing every
128
+ deliverable exit observed in that poll (deduped via `meta/<id>.json`). Waiting
129
+ for idleness prevents an immediately-following `babysit_wait` from racing the
130
+ poller and receiving a duplicate completion.
115
131
  `babysit_kill` and an exit already reported by `babysit_wait` suppress the
116
132
  notification.
117
133
  - **Subagent**: `babysit_wait` blocks on `babysit expect '"type":"agent_end"'`.
package/format-stream.mjs CHANGED
@@ -1,12 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * format-stream.mjs — human-readable view for a `pi --mode json` event stream.
3
+ * format-stream.mjs — human-readable view for a `pi --mode rpc` event stream.
4
4
  *
5
- * babysit records the raw JSONL to its log (machine scrapers + `babysit log`
6
- * stay untouched); this filter only sits on the interactive attach path via
7
- * `babysit run --view-cmd`. It reads the JSONL on stdin and prints a colored,
8
- * incrementally-streamed transcript on stdout so a human attaching the tmux
9
- * pane sees turns, thinking, tool calls and answers instead of a JSON firehose.
5
+ * This filter sits on the interactive attach path via `babysit run --view-cmd`.
6
+ * It accepts both Pi's raw cumulative updates and pi-babysit's compact deltas,
7
+ * then prints a colored, incrementally streamed transcript so a human sees
8
+ * turns, thinking, tool calls and answers instead of a JSON firehose.
10
9
  */
11
10
  import * as readline from "node:readline";
12
11
 
@@ -52,8 +51,8 @@ function summarizeTool(name, a = {}) {
52
51
  }
53
52
 
54
53
  // Join the text of a given block kind ("text" or "thinking") in a message's
55
- // content array. message_update re-emits the whole (growing) message, so we
56
- // compare lengths against what we've already printed to append only the delta.
54
+ // content array. Unfiltered RPC streams repeat the whole growing message;
55
+ // pi-babysit's compact stream instead retains only assistantMessageEvent.delta.
57
56
  function blockText(content, kind) {
58
57
  if (!Array.isArray(content)) return "";
59
58
  let s = "";
@@ -75,15 +74,20 @@ const openBlock = () => {
75
74
  out("\n");
76
75
  };
77
76
 
78
- function streamMessage(msg, fresh = false) {
79
- if (!msg || msg.role !== "assistant") return;
80
- const id = msg.id ?? `t${turn}`;
77
+ function messageState(id = `t${turn}`, fresh = false) {
81
78
  if (fresh) emitted.delete(id);
82
79
  let st = emitted.get(id);
83
80
  if (!st) {
84
81
  st = { think: 0, text: 0, hThink: false, hText: false };
85
82
  emitted.set(id, st);
86
83
  }
84
+ return st;
85
+ }
86
+
87
+ function streamMessage(msg, fresh = false) {
88
+ if (!msg || msg.role !== "assistant") return;
89
+ const id = msg.id ?? `t${turn}`;
90
+ const st = messageState(id, fresh);
87
91
  const think = blockText(msg.content, "thinking");
88
92
  if (think.length > st.think) {
89
93
  if (!st.hThink) {
@@ -106,6 +110,28 @@ function streamMessage(msg, fresh = false) {
106
110
  }
107
111
  }
108
112
 
113
+ function streamDelta(event) {
114
+ if (!event || typeof event.delta !== "string" || !event.delta) return;
115
+ const st = messageState();
116
+ if (event.type === "thinking_delta") {
117
+ if (!st.hThink) {
118
+ openBlock();
119
+ out(`${C.gray}[think] `);
120
+ st.hThink = true;
121
+ }
122
+ out(C.gray + event.delta + C.reset);
123
+ st.think += event.delta.length;
124
+ } else if (event.type === "text_delta") {
125
+ if (!st.hText) {
126
+ openBlock();
127
+ out(`${C.green}[text]${C.reset} `);
128
+ st.hText = true;
129
+ }
130
+ out(event.delta);
131
+ st.text += event.delta.length;
132
+ }
133
+ }
134
+
109
135
  function usageLine(msg) {
110
136
  const u = msg?.usage;
111
137
  if (!u) return;
@@ -138,7 +164,8 @@ rl.on("line", (line) => {
138
164
  streamMessage(ev.message, true);
139
165
  break;
140
166
  case "message_update":
141
- streamMessage(ev.message);
167
+ if (ev.message) streamMessage(ev.message);
168
+ else streamDelta(ev.assistantMessageEvent);
142
169
  break;
143
170
  case "message_end":
144
171
  streamMessage(ev.message);
package/index.ts CHANGED
@@ -36,10 +36,15 @@ import * as fs from "node:fs";
36
36
  import * as os from "node:os";
37
37
  import * as path from "node:path";
38
38
  import { fileURLToPath } from "node:url";
39
- import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
39
+ import type {
40
+ ExtensionAPI,
41
+ ExtensionContext,
42
+ Theme,
43
+ ToolDefinition,
44
+ } from "@earendil-works/pi-coding-agent";
40
45
  import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
41
- import { Box, Container, Markdown, Text } from "@earendil-works/pi-tui";
42
- import { Type } from "typebox";
46
+ import { Box, Markdown, Text } from "@earendil-works/pi-tui";
47
+ import { Type, type TSchema } from "typebox";
43
48
  import { StringEnum } from "@earendil-works/pi-ai";
44
49
  import { type AgentConfig, type AgentScope, discoverAgents } from "./agents";
45
50
 
@@ -130,10 +135,10 @@ export function planSubagentSpawn(
130
135
  // apart from any other turn end (see isParkedToolResult / self-reap.ts).
131
136
  export const NOTIFY_MARKER = "[notify-on-exit]";
132
137
 
133
- // Human-readable view for the subagent JSONL stream when a human attaches.
134
- // babysit records the raw JSON to its log (parsers unaffected) and pipes only
135
- // the live attach view through this formatter. Set PI_BABYSIT_VIEW_CMD="" to
136
- // disable (raw JSON), or to a custom command to override.
138
+ // Human-readable view for the compact subagent JSONL stream when a human
139
+ // attaches. The RPC proxy removes cumulative streaming snapshots before both
140
+ // recording and display; authoritative events remain intact for parsers. Set
141
+ // PI_BABYSIT_VIEW_CMD="" to disable formatting, or provide a custom command.
137
142
  const EXT_DIR = path.dirname(fileURLToPath(import.meta.url));
138
143
  const shq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
139
144
  const VIEW_CMD =
@@ -365,6 +370,10 @@ interface Meta {
365
370
  // Temporary reservation while kill is in flight. Unlike `notified`, this
366
371
  // must be cleared on failure so a real completion remains deliverable.
367
372
  notificationPaused?: boolean;
373
+ // Concurrent explicit waits share a reference-counted notification claim.
374
+ // A timeout must not re-enable the poller while another wait still owns it.
375
+ waitReservations?: number;
376
+ waitCompletionClaimed?: boolean;
368
377
  completionObservedAt?: number;
369
378
  startedAt?: number;
370
379
  // subagent
@@ -405,6 +414,10 @@ export function shouldDeliverProcessCompletion(
405
414
  return meta?.kind === "process" && !meta.notified && !meta.notificationPaused;
406
415
  }
407
416
 
417
+ export function shouldDeferCompletionNotification(agentIsIdle: boolean): boolean {
418
+ return !agentIsIdle;
419
+ }
420
+
408
421
  const kindOf = (id: string): "process" | "subagent" => readMeta(id)?.kind ?? "process";
409
422
 
410
423
  // Compact elapsed formatting: "42s", "3m12s", "1h04m".
@@ -528,14 +541,26 @@ const NOTIFY_COMMAND_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_COMMAND_MAX
528
541
  const NOTIFY_BATCH_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_BATCH_MAX_BYTES", 8_000);
529
542
  const ANSWER_MAX_BYTES = 24_000; // subagent answers / error messages
530
543
 
531
- function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
544
+ export function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
545
+ if (maxBytes <= 0) return "";
532
546
  const buf = Buffer.from(s, "utf8");
533
547
  if (buf.length <= maxBytes) return s;
534
- const half = Math.floor(maxBytes / 2);
535
- // Strip replacement chars from a mid-codepoint cut at the boundary.
536
- const head = buf.subarray(0, half).toString("utf8").replace(/\uFFFD+$/, "");
537
- const tail = buf.subarray(buf.length - half).toString("utf8").replace(/^\uFFFD+/, "");
538
- return `${head}\n… [${buf.length - maxBytes} bytes elided] …\n${tail}`;
548
+
549
+ // The marker counts toward the limit. Recompute a few times because the
550
+ // omitted-byte count can change the marker's digit width.
551
+ let available = maxBytes;
552
+ let marker = "";
553
+ for (let i = 0; i < 3; i++) {
554
+ marker = `\n… [${buf.length - available} bytes elided] …\n`;
555
+ available = Math.max(0, maxBytes - Buffer.byteLength(marker, "utf8"));
556
+ }
557
+ if (Buffer.byteLength(marker, "utf8") > maxBytes) return truncateUtf8End(marker, maxBytes);
558
+ const headBytes = Math.floor(available / 2);
559
+ const tailBytes = available - headBytes;
560
+ // Strip replacement chars from a mid-codepoint cut at either boundary.
561
+ const head = buf.subarray(0, headBytes).toString("utf8").replace(/\uFFFD+$/, "");
562
+ const tail = buf.subarray(buf.length - tailBytes).toString("utf8").replace(/^\uFFFD+/, "");
563
+ return `${head}${marker}${tail}`;
539
564
  }
540
565
 
541
566
  async function searchLog(
@@ -634,7 +659,10 @@ export function summarizeNotificationCommand(command: string | undefined): strin
634
659
  .trim()
635
660
  .replace(/\r/g, "\\r")
636
661
  .replace(/\n/g, "\\n")
637
- .replace(/\t/g, "\\t") || "?";
662
+ .replace(/\t/g, "\\t")
663
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, (char) =>
664
+ `\\x${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`,
665
+ ) || "?";
638
666
  const bytes = Buffer.from(preview, "utf8");
639
667
  if (bytes.length <= NOTIFY_COMMAND_MAX_BYTES) return preview;
640
668
  if (NOTIFY_COMMAND_MAX_BYTES === 0) return "";
@@ -674,6 +702,7 @@ interface ProcessCompletionMessage {
674
702
  status: CompletionStatus;
675
703
  runtime?: string;
676
704
  logPath?: string;
705
+ command?: string;
677
706
  count: number;
678
707
  totalCount: number;
679
708
  remainingCount: number;
@@ -683,6 +712,7 @@ interface ProcessCompletionMessage {
683
712
  success: boolean;
684
713
  status: CompletionStatus;
685
714
  runtime: string;
715
+ command: string | undefined;
686
716
  logPath: string;
687
717
  }>;
688
718
  };
@@ -791,6 +821,7 @@ export function buildProcessCompletionMessage(
791
821
  success: notice.success,
792
822
  status: notice.status,
793
823
  runtime: notice.runtime,
824
+ command: notice.command,
794
825
  logPath: notice.logPath,
795
826
  }));
796
827
  const single = totalCount === 1 ? batch[0] : undefined;
@@ -805,6 +836,7 @@ export function buildProcessCompletionMessage(
805
836
  status,
806
837
  runtime: single?.runtime,
807
838
  logPath: single?.logPath,
839
+ command: single?.command,
808
840
  count: batch.length,
809
841
  totalCount,
810
842
  remainingCount: totalCount - batch.length,
@@ -871,7 +903,7 @@ interface ToolCall {
871
903
  name: string;
872
904
  summary: string;
873
905
  }
874
- interface Progress {
906
+ export interface Progress {
875
907
  turns: number;
876
908
  toolCalls: ToolCall[];
877
909
  finalText: string;
@@ -914,7 +946,7 @@ function summarizeToolCall(name: string, args: Record<string, unknown>): string
914
946
  }
915
947
  }
916
948
 
917
- function parseEvents(logText: string): Progress {
949
+ export function parseEvents(logText: string): Progress {
918
950
  const p: Progress = {
919
951
  turns: 0,
920
952
  toolCalls: [],
@@ -1122,13 +1154,21 @@ async function spawnSubagent(
1122
1154
  "--timeout",
1123
1155
  opts.timeout,
1124
1156
  ];
1125
- // Pretty-print the JSONL stream for humans who `attach`; the recorded log
1126
- // stays raw so parseEvents/`babysit log` are unaffected.
1157
+ // Pretty-print the compact JSONL stream for humans who `attach`. The RPC
1158
+ // proxy removes only cumulative streaming snapshots; authoritative final,
1159
+ // tool, lifecycle, response, and error events remain intact for parseEvents.
1127
1160
  if (VIEW_CMD.trim()) bsArgs.push("--view-cmd", VIEW_CMD);
1128
1161
  if (opts.idleTimeout && opts.idleTimeout !== "none") {
1129
1162
  bsArgs.push("--idle-timeout", opts.idleTimeout);
1130
1163
  }
1131
- bsArgs.push("--", PI_BIN, ...piArgs);
1164
+ bsArgs.push(
1165
+ "--",
1166
+ process.execPath,
1167
+ path.join(EXT_DIR, "rpc-stream-proxy.mjs"),
1168
+ "--",
1169
+ PI_BIN,
1170
+ ...piArgs,
1171
+ );
1132
1172
 
1133
1173
  const r = await bs(bsArgs, {
1134
1174
  cwd: opts.cwd,
@@ -1427,19 +1467,47 @@ function suppressNotify(id: string, reason: "observed" | "kill" = "observed"): v
1427
1467
  }
1428
1468
  }
1429
1469
 
1430
- export function canRestoreNotificationAfterWait(meta: {
1470
+ export interface WaitReservationState {
1431
1471
  notified?: boolean;
1432
1472
  killNotificationSuppressed?: boolean;
1433
- }): boolean {
1434
- return meta.notified === true && meta.killNotificationSuppressed !== true;
1473
+ waitReservations?: number;
1474
+ waitCompletionClaimed?: boolean;
1435
1475
  }
1436
1476
 
1437
- function enableNotify(id: string): void {
1438
- const meta = readMeta(id);
1439
- if (meta && meta.kind === "process" && canRestoreNotificationAfterWait(meta)) {
1440
- meta.notified = false;
1441
- writeMeta(id, meta);
1477
+ export function canRestoreNotificationAfterWait(meta: WaitReservationState): boolean {
1478
+ return (
1479
+ meta.notified === true &&
1480
+ meta.killNotificationSuppressed !== true &&
1481
+ meta.waitCompletionClaimed !== true &&
1482
+ (meta.waitReservations ?? 0) === 0
1483
+ );
1484
+ }
1485
+
1486
+ export function transitionWaitReservation(
1487
+ state: WaitReservationState,
1488
+ action: "reserve" | "abandon" | "claim",
1489
+ ): WaitReservationState {
1490
+ const next = { ...state };
1491
+ if (action === "reserve") {
1492
+ next.waitReservations = (next.waitReservations ?? 0) + 1;
1493
+ next.notified = true;
1494
+ return next;
1442
1495
  }
1496
+
1497
+ next.waitReservations = Math.max(0, (next.waitReservations ?? 0) - 1);
1498
+ if (action === "claim") next.waitCompletionClaimed = true;
1499
+ if (action === "claim" || next.waitCompletionClaimed) {
1500
+ next.notified = true;
1501
+ } else if (canRestoreNotificationAfterWait(next)) {
1502
+ next.notified = false;
1503
+ }
1504
+ return next;
1505
+ }
1506
+
1507
+ function updateWaitReservation(id: string, action: "reserve" | "abandon" | "claim"): void {
1508
+ const meta = readMeta(id);
1509
+ if (!meta || meta.kind !== "process") return;
1510
+ writeMeta(id, { ...meta, ...transitionWaitReservation(meta, action) });
1443
1511
  }
1444
1512
 
1445
1513
  function pauseNotify(id: string): void {
@@ -1493,19 +1561,20 @@ async function waitForExit(
1493
1561
  }
1494
1562
  // fall through: session exited before the pattern appeared
1495
1563
  } else {
1496
- // An explicit wait owns completion delivery. Mark it before blocking so
1497
- // the exit poller cannot race us and inject a duplicate notification.
1498
- suppressNotify(id);
1564
+ // An explicit wait owns completion delivery. Reference-count the claim so
1565
+ // one concurrent wait timing out cannot re-enable notifications underneath
1566
+ // another wait that is still pending.
1567
+ updateWaitReservation(id, "reserve");
1499
1568
  const w = await bs(["wait", "-s", id, "--timeout", t], { signal });
1500
1569
  if (signal?.aborted || w.code === 130) {
1501
- enableNotify(id);
1570
+ updateWaitReservation(id, "abandon");
1502
1571
  return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
1503
1572
  }
1504
1573
  if (w.code === 124) {
1505
1574
  // 124 is ambiguous (timeout vs child exiting 124) — disambiguate.
1506
1575
  const st0 = await statusOf(id);
1507
1576
  if (st0?.state === "running") {
1508
- enableNotify(id);
1577
+ updateWaitReservation(id, "abandon");
1509
1578
  return {
1510
1579
  id,
1511
1580
  kind: "timeout",
@@ -1519,9 +1588,15 @@ async function waitForExit(
1519
1588
 
1520
1589
  const st = await statusOf(id);
1521
1590
  if (!st) {
1591
+ // A non-pattern backend wait returned before this lookup, so preserve its
1592
+ // completion claim when status persistence is transiently unavailable.
1593
+ // Abandoning here can re-enable the poller and duplicate a completion the
1594
+ // explicit wait already consumed. This matches the old suppress-first rule.
1595
+ if (!expectPattern) updateWaitReservation(id, "claim");
1522
1596
  return { id, kind: "exited", ok: false, text: `No such session: ${id}` };
1523
1597
  }
1524
- suppressNotify(id); // the agent sees the exit here; don't notify again
1598
+ if (expectPattern) suppressNotify(id);
1599
+ else updateWaitReservation(id, "claim"); // the agent sees the exit here; don't notify again
1525
1600
  const meta = readMeta(id);
1526
1601
  const workerDead = st.state === "dead" && st.exit_code == null;
1527
1602
  const ok = st.exit_code === 0;
@@ -1573,12 +1648,41 @@ export function isAllowedDirectBash(_command: string): boolean {
1573
1648
  return process.env.PI_BABYSIT_ALLOW_BASH === "1";
1574
1649
  }
1575
1650
 
1651
+ export function activeToolsWithoutDirectBash(activeTools: string[], allowDirectBash: boolean): string[] {
1652
+ return allowDirectBash ? activeTools : activeTools.filter((name) => name !== "bash");
1653
+ }
1654
+
1576
1655
  // ---------------------------------------------------------------------------
1577
1656
  // extension
1578
1657
  // ---------------------------------------------------------------------------
1579
1658
 
1580
1659
  export default function (pi: ExtensionAPI) {
1581
1660
  let pollTimer: ReturnType<typeof setInterval> | undefined;
1661
+ const declaredToolErrors = new Set<string>();
1662
+
1663
+ // Pi only persists custom-tool failures when they are thrown or patched by a
1664
+ // tool_result hook; an `isError` property returned from execute() is ignored.
1665
+ // Keep the structured result (status, log path, diagnostics) while promoting
1666
+ // its declared error bit at the supported hook boundary.
1667
+ const registerTool = <TParams extends TSchema, TDetails, TState>(
1668
+ tool: ToolDefinition<TParams, TDetails, TState>,
1669
+ ): void => {
1670
+ const execute = tool.execute;
1671
+ pi.registerTool({
1672
+ ...tool,
1673
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
1674
+ const result = await execute(toolCallId, params, signal, onUpdate, ctx);
1675
+ if ((result as typeof result & { isError?: boolean }).isError === true) {
1676
+ declaredToolErrors.add(toolCallId);
1677
+ }
1678
+ return result;
1679
+ },
1680
+ });
1681
+ };
1682
+
1683
+ pi.on("tool_result", (event) => {
1684
+ if (declaredToolErrors.delete(event.toolCallId)) return { isError: true };
1685
+ });
1582
1686
 
1583
1687
  // Exit notifications for kind=process sessions: the poller detects
1584
1688
  // running→exited transitions and injects ONE message (triggerTurn) for all
@@ -1586,7 +1690,11 @@ export default function (pi: ExtensionAPI) {
1586
1690
  // that ended its turn after babysit_run without spending one turn per exit.
1587
1691
  // Kills via babysit_kill and exits already reported by babysit_wait are
1588
1692
  // suppressed via meta.notified.
1589
- async function notifyEndedProcesses(): Promise<void> {
1693
+ async function notifyEndedProcesses(ctx: ExtensionContext): Promise<void> {
1694
+ // Never steer a completion into an active agent turn. In particular, this
1695
+ // lets an immediately-following babysit_wait reserve the completion first,
1696
+ // instead of racing the poller and receiving both wait + auto notification.
1697
+ if (shouldDeferCompletionNotification(ctx.isIdle())) return;
1590
1698
  const { sessions } = await listSessions();
1591
1699
  const ready: Array<{ session: BsSession; meta: Meta }> = [];
1592
1700
  for (const session of sessions) {
@@ -1645,6 +1753,9 @@ export default function (pi: ExtensionAPI) {
1645
1753
  metadataById.set(notice.id, current);
1646
1754
  return [{ ...notice, command: current.command }];
1647
1755
  });
1756
+ // Output collection above yields to the event loop. Re-check idleness so a
1757
+ // newly-started agent turn cannot receive a duplicate completion mid-turn.
1758
+ if (shouldDeferCompletionNotification(ctx.isIdle())) return;
1648
1759
  deliverProcessCompletionMessage(
1649
1760
  notices,
1650
1761
  (message, options) => pi.sendMessage(message, options),
@@ -1738,17 +1849,36 @@ export default function (pi: ExtensionAPI) {
1738
1849
  status?: DisplayStatus;
1739
1850
  success?: boolean;
1740
1851
  exitCode?: number | null;
1852
+ command?: string;
1853
+ count?: number;
1741
1854
  };
1742
1855
  const status =
1743
1856
  d.status ?? (d.success ? "success" : d.exitCode == null ? "terminated" : "failed");
1857
+ const payload = d.command
1858
+ ? ` ${summarizeNotificationCommand(d.command)}`
1859
+ : d.count && d.count > 1
1860
+ ? ` ×${d.count}`
1861
+ : "";
1862
+ const header = theme.fg("warning", theme.bold(`babysit_run COMMAND${payload}`));
1744
1863
  const box = new Box(1, 1, (t) => theme.bg("toolSuccessBg", t));
1745
- box.addChild(new Text(renderStatus(status, theme, "babysit_run"), 0, 0));
1864
+ box.addChild(new Text(header, 0, 0));
1865
+ box.addChild(new Text(renderStatus(status, theme), 0, 0));
1746
1866
  box.addChild(new Text(theme.fg("toolOutput", content), 0, 0));
1747
1867
  return box;
1748
1868
  });
1749
1869
 
1750
1870
  let polling = false;
1751
1871
  pi.on("session_start", async (_event, ctx) => {
1872
+ // Do not expose the built-in bash tool only to reject it after the model has
1873
+ // already paid for a failed tool turn. The tool_call hook remains a fallback
1874
+ // if another extension/preset re-enables bash later in the session.
1875
+ const activeTools = pi.getActiveTools();
1876
+ const supervisedTools = activeToolsWithoutDirectBash(
1877
+ activeTools,
1878
+ isAllowedDirectBash(""),
1879
+ );
1880
+ if (supervisedTools.length !== activeTools.length) pi.setActiveTools(supervisedTools);
1881
+
1752
1882
  // Session-local registry: scope the babysit root to this pi session so
1753
1883
  // other sessions' processes/subagents are invisible here. Resuming a
1754
1884
  // session keeps the same id, so its sessions come back with it.
@@ -1768,7 +1898,7 @@ export default function (pi: ExtensionAPI) {
1768
1898
  // calls never stack up.
1769
1899
  if (polling) return;
1770
1900
  polling = true;
1771
- Promise.all([notifyEndedProcesses(), refreshWidget(ctx)])
1901
+ Promise.all([notifyEndedProcesses(ctx), refreshWidget(ctx)])
1772
1902
  .catch(() => {
1773
1903
  /* ignore poll errors */
1774
1904
  })
@@ -1805,7 +1935,7 @@ export default function (pi: ExtensionAPI) {
1805
1935
  });
1806
1936
 
1807
1937
  // ----- babysit_run --------------------------------------------------------
1808
- pi.registerTool({
1938
+ registerTool({
1809
1939
  name: "babysit_run",
1810
1940
  label: "Babysit: run",
1811
1941
  description:
@@ -1826,7 +1956,8 @@ export default function (pi: ExtensionAPI) {
1826
1956
  "Run any shell command with context-safe captured output; quick commands return metadata, longer ones continue in background",
1827
1957
  promptGuidelines: [
1828
1958
  "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`.",
1829
- "Inspect a babysit log with babysit_check { id, lines, pattern? }; never read or cat a potentially large log file in full.",
1959
+ "Bundle closely related tiny observations into one babysit_run command when that reduces tool turns without obscuring lifecycle or failure handling.",
1960
+ "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.",
1830
1961
  "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').",
1831
1962
  "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.",
1832
1963
  "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 }.",
@@ -2129,10 +2260,19 @@ export default function (pi: ExtensionAPI) {
2129
2260
  },
2130
2261
  };
2131
2262
  },
2132
- // The result label already includes "babysit_run"; suppress the default
2133
- // call header so the tool name is not shown twice in adjacent lines.
2134
- renderCall() {
2135
- return new Container();
2263
+ // Make the execution kind and payload identifiable before the result arrives.
2264
+ // Use semantic theme colors rather than the fallback gray tool-call text.
2265
+ renderCall(args, theme) {
2266
+ const isSubagent = args.profile === "subagent";
2267
+ const kind = isSubagent ? "AGENT" : "COMMAND";
2268
+ const payload = isSubagent ? args.task : args.command;
2269
+ const agent = isSubagent && args.agent ? ` [${args.agent}]` : "";
2270
+ const title = theme.bold(`babysit_run ${kind}`);
2271
+ const detail =
2272
+ typeof payload === "string" && payload.length > 0
2273
+ ? ` ${summarizeNotificationCommand(payload)}`
2274
+ : "";
2275
+ return new Text(theme.fg("warning", `${title}${agent}${detail}`), 0, 0);
2136
2276
  },
2137
2277
  renderResult(result, { isPartial }, theme, context) {
2138
2278
  const details = (result.details ?? {}) as {
@@ -2160,13 +2300,13 @@ export default function (pi: ExtensionAPI) {
2160
2300
  : details.kind === "subagent" || content.includes(NOTIFY_MARKER)
2161
2301
  ? "started"
2162
2302
  : "success");
2163
- const label = renderStatus(status, theme, "babysit_run");
2303
+ const label = renderStatus(status, theme);
2164
2304
  return new Text(content ? `${label}\n${theme.fg("toolOutput", content)}` : label, 0, 0);
2165
2305
  },
2166
2306
  });
2167
2307
 
2168
2308
  // ----- babysit_check ------------------------------------------------------
2169
- pi.registerTool({
2309
+ registerTool({
2170
2310
  name: "babysit_check",
2171
2311
  label: "Babysit: check",
2172
2312
  description:
@@ -2224,7 +2364,7 @@ export default function (pi: ExtensionAPI) {
2224
2364
  const preview = what.length > 60 ? `${what.slice(0, 57)}…` : what;
2225
2365
  return `${s.id} [${kind}] ${s.state}${ec}${depth}${flag}${preview ? ` — ${preview}` : ""}`;
2226
2366
  });
2227
- return { content: [{ type: "text", text: lines.join("\n") }], details: { sessions } };
2367
+ return { content: [{ type: "text", text: clip(lines.join("\n")) }], details: { sessions } };
2228
2368
  }
2229
2369
 
2230
2370
  const st = await statusOf(params.id);
@@ -2266,7 +2406,7 @@ export default function (pi: ExtensionAPI) {
2266
2406
  ? `--- latest matches /${params.pattern}/ ---\n${result.text}`
2267
2407
  : `(no output matching /${params.pattern}/)`;
2268
2408
  return {
2269
- content: [{ type: "text", text: `${header}\n${body}` }],
2409
+ content: [{ type: "text", text: clip(`${header}\n${body}`) }],
2270
2410
  details: { status: st, kind, logPath: logPath(params.id), pattern: params.pattern },
2271
2411
  };
2272
2412
  }
@@ -2294,7 +2434,7 @@ export default function (pi: ExtensionAPI) {
2294
2434
  parts.push(tail ? `--- recent output ---\n${tail}` : "(no output yet)");
2295
2435
  }
2296
2436
  return {
2297
- content: [{ type: "text", text: parts.join("\n") }],
2437
+ content: [{ type: "text", text: clip(parts.join("\n")) }],
2298
2438
  details: { status: st, kind: "process", logPath: logPath(params.id) },
2299
2439
  };
2300
2440
  }
@@ -2356,7 +2496,7 @@ export default function (pi: ExtensionAPI) {
2356
2496
  });
2357
2497
 
2358
2498
  // ----- babysit_send -------------------------------------------------------
2359
- pi.registerTool({
2499
+ registerTool({
2360
2500
  name: "babysit_send",
2361
2501
  label: "Babysit: send",
2362
2502
  description:
@@ -2509,7 +2649,7 @@ export default function (pi: ExtensionAPI) {
2509
2649
  });
2510
2650
 
2511
2651
  // ----- babysit_wait -------------------------------------------------------
2512
- pi.registerTool({
2652
+ registerTool({
2513
2653
  name: "babysit_wait",
2514
2654
  label: "Babysit: wait",
2515
2655
  description:
@@ -2620,7 +2760,7 @@ export default function (pi: ExtensionAPI) {
2620
2760
  });
2621
2761
 
2622
2762
  // ----- babysit_kill -------------------------------------------------------
2623
- pi.registerTool({
2763
+ registerTool({
2624
2764
  name: "babysit_kill",
2625
2765
  label: "Babysit: kill",
2626
2766
  description: "Terminate a babysit session (process or subagent).",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.3.9",
3
+ "version": "0.3.12",
4
4
  "description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -23,6 +23,7 @@
23
23
  "agents.ts",
24
24
  "self-reap.ts",
25
25
  "format-stream.mjs",
26
+ "rpc-stream-proxy.mjs",
26
27
  "search-log.mjs",
27
28
  "README.md",
28
29
  "LICENSE"
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Transparent stdin/stdout proxy for `pi --mode rpc`.
4
+ *
5
+ * Pi's `message_update` events repeat the entire growing assistant message and
6
+ * `assistantMessageEvent.partial` on every token. Those cumulative snapshots
7
+ * are useful only while streaming; `message_end` is the authoritative final
8
+ * message. Persisting every snapshot makes babysit logs grow quadratically.
9
+ *
10
+ * This proxy forwards stdin byte-for-byte and compacts only `message_update`
11
+ * lines on stdout. All final, tool, lifecycle, response, and error events pass
12
+ * through byte-for-byte.
13
+ */
14
+ import { spawn } from "node:child_process";
15
+ import { StringDecoder } from "node:string_decoder";
16
+ import { pathToFileURL } from "node:url";
17
+
18
+ export function compactRpcLine(line) {
19
+ if (!line.trimStart().startsWith("{")) return line;
20
+
21
+ let event;
22
+ try {
23
+ event = JSON.parse(line);
24
+ } catch {
25
+ return line;
26
+ }
27
+ if (event?.type !== "message_update") return line;
28
+
29
+ const { message: _cumulativeMessage, assistantMessageEvent, ...rest } = event;
30
+ if (!assistantMessageEvent || typeof assistantMessageEvent !== "object") {
31
+ return JSON.stringify(rest);
32
+ }
33
+ const { partial: _cumulativePartial, ...deltaEvent } = assistantMessageEvent;
34
+ return JSON.stringify({ ...rest, assistantMessageEvent: deltaEvent });
35
+ }
36
+
37
+ function transformRpcOutput(input, output) {
38
+ return new Promise((resolve, reject) => {
39
+ const decoder = new StringDecoder("utf8");
40
+ let buffered = "";
41
+
42
+ const write = (value) => {
43
+ if (!output.write(value)) input.pause();
44
+ };
45
+ output.on("drain", () => input.resume());
46
+ input.on("error", reject);
47
+ input.on("data", (chunk) => {
48
+ buffered += decoder.write(chunk);
49
+ for (;;) {
50
+ const newline = buffered.indexOf("\n");
51
+ if (newline < 0) break;
52
+ const line = buffered.slice(0, newline);
53
+ buffered = buffered.slice(newline + 1);
54
+ write(`${compactRpcLine(line)}\n`);
55
+ }
56
+ });
57
+ input.on("end", () => {
58
+ buffered += decoder.end();
59
+ if (buffered) write(compactRpcLine(buffered));
60
+ if (output.writableNeedDrain) output.once("drain", resolve);
61
+ else resolve();
62
+ });
63
+ });
64
+ }
65
+
66
+ function waitForDrain(stream) {
67
+ return stream.writableNeedDrain
68
+ ? new Promise((resolve) => stream.once("drain", resolve))
69
+ : Promise.resolve();
70
+ }
71
+
72
+ export function runRpcProxy(argv = process.argv.slice(2)) {
73
+ const separator = argv.indexOf("--");
74
+ const command = separator >= 0 ? argv[separator + 1] : argv[0];
75
+ const args = separator >= 0 ? argv.slice(separator + 2) : argv.slice(1);
76
+ if (!command) {
77
+ console.error("Usage: rpc-stream-proxy.mjs -- <command> [args...]");
78
+ process.exitCode = 2;
79
+ return;
80
+ }
81
+
82
+ const child = spawn(command, args, {
83
+ cwd: process.cwd(),
84
+ env: process.env,
85
+ stdio: ["pipe", "pipe", "pipe"],
86
+ });
87
+
88
+ let transportFailed = false;
89
+ process.stdin.pipe(child.stdin);
90
+ // The RPC worker can exit or self-reap while a command is still arriving.
91
+ // Ignore the resulting pipe-close race so the proxy preserves the worker's
92
+ // actual exit code/signal instead of crashing with an unhandled EPIPE.
93
+ child.stdin.on("error", (error) => {
94
+ if (error?.code === "EPIPE" || error?.code === "ERR_STREAM_DESTROYED") return;
95
+ transportFailed = true;
96
+ console.error(`rpc stream proxy stdin failed: ${error.message}`);
97
+ });
98
+ const outputFlushed = transformRpcOutput(child.stdout, process.stdout);
99
+ child.stderr.pipe(process.stderr);
100
+
101
+ const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP"];
102
+ const signalHandlers = new Map(
103
+ forwardedSignals.map((signal) => [signal, () => child.kill(signal)]),
104
+ );
105
+ for (const [signal, handler] of signalHandlers) process.on(signal, handler);
106
+ let spawnFailed = false;
107
+ child.on("error", (error) => {
108
+ spawnFailed = true;
109
+ console.error(`rpc stream proxy failed to start ${command}: ${error.message}`);
110
+ });
111
+ child.on("close", async (code, signal) => {
112
+ try {
113
+ await outputFlushed;
114
+ await Promise.all([waitForDrain(process.stdout), waitForDrain(process.stderr)]);
115
+ } catch (error) {
116
+ console.error(`rpc stream proxy output failed: ${error.message}`);
117
+ process.exitCode = 1;
118
+ return;
119
+ }
120
+ if (transportFailed) {
121
+ process.exitCode = 1;
122
+ return;
123
+ }
124
+ if (signal) {
125
+ for (const [forwarded, handler] of signalHandlers) process.off(forwarded, handler);
126
+ process.kill(process.pid, signal);
127
+ return;
128
+ }
129
+ process.exitCode = spawnFailed ? 1 : (code ?? 1);
130
+ });
131
+ }
132
+
133
+ const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : undefined;
134
+ if (entry === import.meta.url) runRpcProxy();