@yusukeshib/pi-babysit 0.3.9 → 0.3.11

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
@@ -95,6 +95,14 @@ Tail and search results are capped at 200 lines and clipped to 8 KB. Pattern
95
95
  search returns the latest matching lines with line numbers. Do not read a
96
96
  potentially large log file in full.
97
97
 
98
+ Subagent logs also compact Pi's streaming `message_update` events before they
99
+ are recorded. Pi repeats the complete growing assistant message and partial
100
+ snapshot on every token; pi-babysit retains only the incremental delta while
101
+ leaving authoritative `message_end`, tool, lifecycle, response, and error
102
+ events untouched. This keeps long RPC sessions approximately linear in emitted
103
+ content without changing final answers, completion detection, or follow-up
104
+ behavior. Live `/babysit` and attach views render the retained deltas.
105
+
98
106
  All shell commands, including `pwd` and Git, are redirected to `babysit_run`.
99
107
  Set `PI_BABYSIT_ALLOW_BASH=1` only as an explicit emergency escape hatch.
100
108
 
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
@@ -38,7 +38,7 @@ import * as path from "node:path";
38
38
  import { fileURLToPath } from "node:url";
39
39
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
40
40
  import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
41
- import { Box, Container, Markdown, Text } from "@earendil-works/pi-tui";
41
+ import { Box, Markdown, Text } from "@earendil-works/pi-tui";
42
42
  import { Type } from "typebox";
43
43
  import { StringEnum } from "@earendil-works/pi-ai";
44
44
  import { type AgentConfig, type AgentScope, discoverAgents } from "./agents";
@@ -130,10 +130,10 @@ export function planSubagentSpawn(
130
130
  // apart from any other turn end (see isParkedToolResult / self-reap.ts).
131
131
  export const NOTIFY_MARKER = "[notify-on-exit]";
132
132
 
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.
133
+ // Human-readable view for the compact subagent JSONL stream when a human
134
+ // attaches. The RPC proxy removes cumulative streaming snapshots before both
135
+ // recording and display; authoritative events remain intact for parsers. Set
136
+ // PI_BABYSIT_VIEW_CMD="" to disable formatting, or provide a custom command.
137
137
  const EXT_DIR = path.dirname(fileURLToPath(import.meta.url));
138
138
  const shq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
139
139
  const VIEW_CMD =
@@ -634,7 +634,10 @@ export function summarizeNotificationCommand(command: string | undefined): strin
634
634
  .trim()
635
635
  .replace(/\r/g, "\\r")
636
636
  .replace(/\n/g, "\\n")
637
- .replace(/\t/g, "\\t") || "?";
637
+ .replace(/\t/g, "\\t")
638
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, (char) =>
639
+ `\\x${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`,
640
+ ) || "?";
638
641
  const bytes = Buffer.from(preview, "utf8");
639
642
  if (bytes.length <= NOTIFY_COMMAND_MAX_BYTES) return preview;
640
643
  if (NOTIFY_COMMAND_MAX_BYTES === 0) return "";
@@ -674,6 +677,7 @@ interface ProcessCompletionMessage {
674
677
  status: CompletionStatus;
675
678
  runtime?: string;
676
679
  logPath?: string;
680
+ command?: string;
677
681
  count: number;
678
682
  totalCount: number;
679
683
  remainingCount: number;
@@ -683,6 +687,7 @@ interface ProcessCompletionMessage {
683
687
  success: boolean;
684
688
  status: CompletionStatus;
685
689
  runtime: string;
690
+ command: string | undefined;
686
691
  logPath: string;
687
692
  }>;
688
693
  };
@@ -791,6 +796,7 @@ export function buildProcessCompletionMessage(
791
796
  success: notice.success,
792
797
  status: notice.status,
793
798
  runtime: notice.runtime,
799
+ command: notice.command,
794
800
  logPath: notice.logPath,
795
801
  }));
796
802
  const single = totalCount === 1 ? batch[0] : undefined;
@@ -805,6 +811,7 @@ export function buildProcessCompletionMessage(
805
811
  status,
806
812
  runtime: single?.runtime,
807
813
  logPath: single?.logPath,
814
+ command: single?.command,
808
815
  count: batch.length,
809
816
  totalCount,
810
817
  remainingCount: totalCount - batch.length,
@@ -871,7 +878,7 @@ interface ToolCall {
871
878
  name: string;
872
879
  summary: string;
873
880
  }
874
- interface Progress {
881
+ export interface Progress {
875
882
  turns: number;
876
883
  toolCalls: ToolCall[];
877
884
  finalText: string;
@@ -914,7 +921,7 @@ function summarizeToolCall(name: string, args: Record<string, unknown>): string
914
921
  }
915
922
  }
916
923
 
917
- function parseEvents(logText: string): Progress {
924
+ export function parseEvents(logText: string): Progress {
918
925
  const p: Progress = {
919
926
  turns: 0,
920
927
  toolCalls: [],
@@ -1122,13 +1129,21 @@ async function spawnSubagent(
1122
1129
  "--timeout",
1123
1130
  opts.timeout,
1124
1131
  ];
1125
- // Pretty-print the JSONL stream for humans who `attach`; the recorded log
1126
- // stays raw so parseEvents/`babysit log` are unaffected.
1132
+ // Pretty-print the compact JSONL stream for humans who `attach`. The RPC
1133
+ // proxy removes only cumulative streaming snapshots; authoritative final,
1134
+ // tool, lifecycle, response, and error events remain intact for parseEvents.
1127
1135
  if (VIEW_CMD.trim()) bsArgs.push("--view-cmd", VIEW_CMD);
1128
1136
  if (opts.idleTimeout && opts.idleTimeout !== "none") {
1129
1137
  bsArgs.push("--idle-timeout", opts.idleTimeout);
1130
1138
  }
1131
- bsArgs.push("--", PI_BIN, ...piArgs);
1139
+ bsArgs.push(
1140
+ "--",
1141
+ process.execPath,
1142
+ path.join(EXT_DIR, "rpc-stream-proxy.mjs"),
1143
+ "--",
1144
+ PI_BIN,
1145
+ ...piArgs,
1146
+ );
1132
1147
 
1133
1148
  const r = await bs(bsArgs, {
1134
1149
  cwd: opts.cwd,
@@ -1738,11 +1753,20 @@ export default function (pi: ExtensionAPI) {
1738
1753
  status?: DisplayStatus;
1739
1754
  success?: boolean;
1740
1755
  exitCode?: number | null;
1756
+ command?: string;
1757
+ count?: number;
1741
1758
  };
1742
1759
  const status =
1743
1760
  d.status ?? (d.success ? "success" : d.exitCode == null ? "terminated" : "failed");
1761
+ const payload = d.command
1762
+ ? ` ${summarizeNotificationCommand(d.command)}`
1763
+ : d.count && d.count > 1
1764
+ ? ` ×${d.count}`
1765
+ : "";
1766
+ const header = theme.fg("warning", theme.bold(`babysit_run COMMAND${payload}`));
1744
1767
  const box = new Box(1, 1, (t) => theme.bg("toolSuccessBg", t));
1745
- box.addChild(new Text(renderStatus(status, theme, "babysit_run"), 0, 0));
1768
+ box.addChild(new Text(header, 0, 0));
1769
+ box.addChild(new Text(renderStatus(status, theme), 0, 0));
1746
1770
  box.addChild(new Text(theme.fg("toolOutput", content), 0, 0));
1747
1771
  return box;
1748
1772
  });
@@ -2129,10 +2153,19 @@ export default function (pi: ExtensionAPI) {
2129
2153
  },
2130
2154
  };
2131
2155
  },
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();
2156
+ // Make the execution kind and payload identifiable before the result arrives.
2157
+ // Use semantic theme colors rather than the fallback gray tool-call text.
2158
+ renderCall(args, theme) {
2159
+ const isSubagent = args.profile === "subagent";
2160
+ const kind = isSubagent ? "AGENT" : "COMMAND";
2161
+ const payload = isSubagent ? args.task : args.command;
2162
+ const agent = isSubagent && args.agent ? ` [${args.agent}]` : "";
2163
+ const title = theme.bold(`babysit_run ${kind}`);
2164
+ const detail =
2165
+ typeof payload === "string" && payload.length > 0
2166
+ ? ` ${summarizeNotificationCommand(payload)}`
2167
+ : "";
2168
+ return new Text(theme.fg("warning", `${title}${agent}${detail}`), 0, 0);
2136
2169
  },
2137
2170
  renderResult(result, { isPartial }, theme, context) {
2138
2171
  const details = (result.details ?? {}) as {
@@ -2160,7 +2193,7 @@ export default function (pi: ExtensionAPI) {
2160
2193
  : details.kind === "subagent" || content.includes(NOTIFY_MARKER)
2161
2194
  ? "started"
2162
2195
  : "success");
2163
- const label = renderStatus(status, theme, "babysit_run");
2196
+ const label = renderStatus(status, theme);
2164
2197
  return new Text(content ? `${label}\n${theme.fg("toolOutput", content)}` : label, 0, 0);
2165
2198
  },
2166
2199
  });
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.11",
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();