@yusukeshib/pi-babysit 0.3.8 → 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 +16 -1
- package/format-stream.mjs +39 -12
- package/index.ts +186 -24
- package/package.json +2 -1
- package/rpc-stream-proxy.mjs +134 -0
package/README.md
CHANGED
|
@@ -40,6 +40,13 @@ The **profile is a tool parameter, not a separate tool set**: domain knowledge
|
|
|
40
40
|
message delivery, spawn validation) lives in code, while the LLM sees one small
|
|
41
41
|
generic surface.
|
|
42
42
|
|
|
43
|
+
Subagent recursion is **disabled by default**: a top-level pi may create workers
|
|
44
|
+
(depth 1), but those workers cannot create more workers. A top-level caller can
|
|
45
|
+
explicitly opt in for a specific tree with `maxDepth: 2` (or higher) when it
|
|
46
|
+
creates the first worker. Descendants inherit that ceiling and cannot raise it.
|
|
47
|
+
Normal `babysit_run { command }` process execution remains available at every
|
|
48
|
+
depth.
|
|
49
|
+
|
|
43
50
|
Because sessions are real PTYs, the agent can also **drive interactive
|
|
44
51
|
programs** (installers, wizards, REPLs): type with `babysit_send`
|
|
45
52
|
(text or named keys) and read the rendered screen with
|
|
@@ -49,7 +56,7 @@ programs** (installers, wizards, REPLs): type with `babysit_send`
|
|
|
49
56
|
|
|
50
57
|
| Tool | What it does |
|
|
51
58
|
| ---- | ------------ |
|
|
52
|
-
| `babysit_run` | Run any command (`command`, optional `name`/`pty`/`timeout`/`idleTimeout`/`retryOnWorkerDeath`) or start a subagent (`profile: "subagent"`, `task`, optional `agent`/`model`/`tools`). Quick commands return inline; longer ones continue in the background |
|
|
59
|
+
| `babysit_run` | Run any command (`command`, optional `name`/`pty`/`timeout`/`idleTimeout`/`retryOnWorkerDeath`) or start a subagent (`profile: "subagent"`, `task`, optional `agent`/`model`/`tools`/`maxDepth`). `maxDepth` defaults to 1 and can only be set by the top-level caller. Quick commands return inline; longer ones continue in the background |
|
|
53
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 |
|
|
54
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`) |
|
|
55
62
|
| `babysit_wait` | Block until done: process exit (or `expect: "regex"` readiness marker), subagent task completion. Multi-wait: `ids` + `mode: "any"\|"all"` |
|
|
@@ -88,6 +95,14 @@ Tail and search results are capped at 200 lines and clipped to 8 KB. Pattern
|
|
|
88
95
|
search returns the latest matching lines with line numbers. Do not read a
|
|
89
96
|
potentially large log file in full.
|
|
90
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
|
+
|
|
91
106
|
All shell commands, including `pwd` and Git, are redirected to `babysit_run`.
|
|
92
107
|
Set `PI_BABYSIT_ALLOW_BASH=1` only as an explicit emergency escape hatch.
|
|
93
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
|
|
3
|
+
* format-stream.mjs — human-readable view for a `pi --mode rpc` event stream.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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.
|
|
56
|
-
//
|
|
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
|
|
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,
|
|
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";
|
|
@@ -52,16 +52,88 @@ let ROOT = ROOT_BASE;
|
|
|
52
52
|
const PI_BIN = process.env.PI_BABYSIT_BIN ?? "pi";
|
|
53
53
|
const BABYSIT_BIN = process.env.PI_BABYSIT_CLI ?? "babysit";
|
|
54
54
|
const SHELL = process.env.SHELL ?? "sh";
|
|
55
|
+
const SUBAGENT_DEPTH_ENV = "PI_BABYSIT_INTERNAL_SUBAGENT_DEPTH";
|
|
56
|
+
const SUBAGENT_MAX_DEPTH_ENV = "PI_BABYSIT_INTERNAL_SUBAGENT_MAX_DEPTH";
|
|
57
|
+
const DEFAULT_SUBAGENT_MAX_DEPTH = 1;
|
|
58
|
+
|
|
59
|
+
export type SubagentSpawnPlan =
|
|
60
|
+
| { allowed: true; childDepth: number; maxDepth: number }
|
|
61
|
+
| { allowed: false; error: string };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Plan a subagent spawn without letting an already-spawned worker raise its
|
|
65
|
+
* inherited recursion allowance. Depth 0 is the user-facing pi process; the
|
|
66
|
+
* first worker is depth 1 and is allowed by default, but that worker cannot
|
|
67
|
+
* create depth 2 unless its top-level parent explicitly opted in.
|
|
68
|
+
*/
|
|
69
|
+
export function planSubagentSpawn(
|
|
70
|
+
requestedMaxDepth?: number,
|
|
71
|
+
env: Record<string, string | undefined> = process.env,
|
|
72
|
+
): SubagentSpawnPlan {
|
|
73
|
+
if (
|
|
74
|
+
requestedMaxDepth !== undefined &&
|
|
75
|
+
(!Number.isInteger(requestedMaxDepth) || requestedMaxDepth < 1)
|
|
76
|
+
) {
|
|
77
|
+
return { allowed: false, error: "`maxDepth` must be a positive integer." };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const rawDepth = env[SUBAGENT_DEPTH_ENV];
|
|
81
|
+
let currentDepth = 0;
|
|
82
|
+
if (rawDepth !== undefined) {
|
|
83
|
+
currentDepth = Number(rawDepth);
|
|
84
|
+
if (!Number.isInteger(currentDepth) || currentDepth < 0) {
|
|
85
|
+
return {
|
|
86
|
+
allowed: false,
|
|
87
|
+
error: `Invalid inherited subagent depth ${JSON.stringify(rawDepth)}; refusing to spawn recursively.`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const nested = currentDepth > 0;
|
|
93
|
+
if (nested && requestedMaxDepth !== undefined) {
|
|
94
|
+
return {
|
|
95
|
+
allowed: false,
|
|
96
|
+
error:
|
|
97
|
+
`Nested subagents cannot override \`maxDepth\` (current depth ${currentDepth}). ` +
|
|
98
|
+
"Only the top-level parent may opt in when it creates the first subagent.",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let maxDepth = requestedMaxDepth ?? DEFAULT_SUBAGENT_MAX_DEPTH;
|
|
103
|
+
if (nested) {
|
|
104
|
+
const rawMaxDepth = env[SUBAGENT_MAX_DEPTH_ENV];
|
|
105
|
+
// Missing/corrupt inherited state fails closed at the current depth.
|
|
106
|
+
maxDepth = rawMaxDepth === undefined ? currentDepth : Number(rawMaxDepth);
|
|
107
|
+
if (!Number.isInteger(maxDepth) || maxDepth < currentDepth) {
|
|
108
|
+
return {
|
|
109
|
+
allowed: false,
|
|
110
|
+
error: `Invalid inherited max subagent depth ${JSON.stringify(rawMaxDepth)}; refusing to spawn recursively.`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const childDepth = currentDepth + 1;
|
|
116
|
+
if (childDepth > maxDepth) {
|
|
117
|
+
return {
|
|
118
|
+
allowed: false,
|
|
119
|
+
error:
|
|
120
|
+
`Nested subagent creation is disabled at depth ${currentDepth}: spawning would reach depth ${childDepth}, ` +
|
|
121
|
+
`but the inherited maxDepth is ${maxDepth}. Have the top-level parent explicitly opt in with ` +
|
|
122
|
+
`babysit_run { profile: "subagent", task, maxDepth: ${childDepth} } when creating the first worker.`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return { allowed: true, childDepth, maxDepth };
|
|
126
|
+
}
|
|
55
127
|
|
|
56
128
|
// Marker embedded in babysit_run's tool RESULT text for kind=process runs.
|
|
57
129
|
// It is how "the turn parked awaiting a process-exit notification" is told
|
|
58
130
|
// apart from any other turn end (see isParkedToolResult / self-reap.ts).
|
|
59
131
|
export const NOTIFY_MARKER = "[notify-on-exit]";
|
|
60
132
|
|
|
61
|
-
// Human-readable view for the subagent JSONL stream when a human
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// disable
|
|
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.
|
|
65
137
|
const EXT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
66
138
|
const shq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
67
139
|
const VIEW_CMD =
|
|
@@ -116,7 +188,11 @@ function normalizeSession(s: BsSession): BsSession {
|
|
|
116
188
|
// long wait be interrupted (Ctrl-C) by killing the child.
|
|
117
189
|
function bs(
|
|
118
190
|
args: string[],
|
|
119
|
-
opts: {
|
|
191
|
+
opts: {
|
|
192
|
+
cwd?: string;
|
|
193
|
+
signal?: AbortSignal;
|
|
194
|
+
env?: Record<string, string | undefined>;
|
|
195
|
+
} = {},
|
|
120
196
|
): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
121
197
|
return new Promise((resolve) => {
|
|
122
198
|
if (opts.signal?.aborted) {
|
|
@@ -125,7 +201,7 @@ function bs(
|
|
|
125
201
|
}
|
|
126
202
|
const child = spawn(BABYSIT_BIN, args, {
|
|
127
203
|
cwd: opts.cwd,
|
|
128
|
-
env: { ...process.env, BABYSIT_DIR: ROOT },
|
|
204
|
+
env: { ...process.env, ...opts.env, BABYSIT_DIR: ROOT },
|
|
129
205
|
});
|
|
130
206
|
let stdout = "";
|
|
131
207
|
let stderr = "";
|
|
@@ -295,6 +371,8 @@ interface Meta {
|
|
|
295
371
|
task?: string;
|
|
296
372
|
promptOffset?: number;
|
|
297
373
|
model?: string;
|
|
374
|
+
depth?: number;
|
|
375
|
+
maxDepth?: number;
|
|
298
376
|
}
|
|
299
377
|
|
|
300
378
|
const metaDir = () => path.join(ROOT, "meta");
|
|
@@ -556,7 +634,10 @@ export function summarizeNotificationCommand(command: string | undefined): strin
|
|
|
556
634
|
.trim()
|
|
557
635
|
.replace(/\r/g, "\\r")
|
|
558
636
|
.replace(/\n/g, "\\n")
|
|
559
|
-
.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
|
+
) || "?";
|
|
560
641
|
const bytes = Buffer.from(preview, "utf8");
|
|
561
642
|
if (bytes.length <= NOTIFY_COMMAND_MAX_BYTES) return preview;
|
|
562
643
|
if (NOTIFY_COMMAND_MAX_BYTES === 0) return "";
|
|
@@ -596,6 +677,7 @@ interface ProcessCompletionMessage {
|
|
|
596
677
|
status: CompletionStatus;
|
|
597
678
|
runtime?: string;
|
|
598
679
|
logPath?: string;
|
|
680
|
+
command?: string;
|
|
599
681
|
count: number;
|
|
600
682
|
totalCount: number;
|
|
601
683
|
remainingCount: number;
|
|
@@ -605,6 +687,7 @@ interface ProcessCompletionMessage {
|
|
|
605
687
|
success: boolean;
|
|
606
688
|
status: CompletionStatus;
|
|
607
689
|
runtime: string;
|
|
690
|
+
command: string | undefined;
|
|
608
691
|
logPath: string;
|
|
609
692
|
}>;
|
|
610
693
|
};
|
|
@@ -713,6 +796,7 @@ export function buildProcessCompletionMessage(
|
|
|
713
796
|
success: notice.success,
|
|
714
797
|
status: notice.status,
|
|
715
798
|
runtime: notice.runtime,
|
|
799
|
+
command: notice.command,
|
|
716
800
|
logPath: notice.logPath,
|
|
717
801
|
}));
|
|
718
802
|
const single = totalCount === 1 ? batch[0] : undefined;
|
|
@@ -727,6 +811,7 @@ export function buildProcessCompletionMessage(
|
|
|
727
811
|
status,
|
|
728
812
|
runtime: single?.runtime,
|
|
729
813
|
logPath: single?.logPath,
|
|
814
|
+
command: single?.command,
|
|
730
815
|
count: batch.length,
|
|
731
816
|
totalCount,
|
|
732
817
|
remainingCount: totalCount - batch.length,
|
|
@@ -793,7 +878,7 @@ interface ToolCall {
|
|
|
793
878
|
name: string;
|
|
794
879
|
summary: string;
|
|
795
880
|
}
|
|
796
|
-
interface Progress {
|
|
881
|
+
export interface Progress {
|
|
797
882
|
turns: number;
|
|
798
883
|
toolCalls: ToolCall[];
|
|
799
884
|
finalText: string;
|
|
@@ -836,7 +921,7 @@ function summarizeToolCall(name: string, args: Record<string, unknown>): string
|
|
|
836
921
|
}
|
|
837
922
|
}
|
|
838
923
|
|
|
839
|
-
function parseEvents(logText: string): Progress {
|
|
924
|
+
export function parseEvents(logText: string): Progress {
|
|
840
925
|
const p: Progress = {
|
|
841
926
|
turns: 0,
|
|
842
927
|
toolCalls: [],
|
|
@@ -1003,6 +1088,8 @@ interface SubagentOpts {
|
|
|
1003
1088
|
model?: string;
|
|
1004
1089
|
tools?: string[];
|
|
1005
1090
|
cwd: string;
|
|
1091
|
+
depth: number;
|
|
1092
|
+
maxDepth: number;
|
|
1006
1093
|
// Idle-timeout is OFF by default: an RPC-mode pi is silent while it works,
|
|
1007
1094
|
// so idle detection would false-kill a busy subagent. The absolute timeout
|
|
1008
1095
|
// is the safety valve instead.
|
|
@@ -1042,15 +1129,29 @@ async function spawnSubagent(
|
|
|
1042
1129
|
"--timeout",
|
|
1043
1130
|
opts.timeout,
|
|
1044
1131
|
];
|
|
1045
|
-
// Pretty-print the JSONL stream for humans who `attach
|
|
1046
|
-
//
|
|
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.
|
|
1047
1135
|
if (VIEW_CMD.trim()) bsArgs.push("--view-cmd", VIEW_CMD);
|
|
1048
1136
|
if (opts.idleTimeout && opts.idleTimeout !== "none") {
|
|
1049
1137
|
bsArgs.push("--idle-timeout", opts.idleTimeout);
|
|
1050
1138
|
}
|
|
1051
|
-
bsArgs.push(
|
|
1139
|
+
bsArgs.push(
|
|
1140
|
+
"--",
|
|
1141
|
+
process.execPath,
|
|
1142
|
+
path.join(EXT_DIR, "rpc-stream-proxy.mjs"),
|
|
1143
|
+
"--",
|
|
1144
|
+
PI_BIN,
|
|
1145
|
+
...piArgs,
|
|
1146
|
+
);
|
|
1052
1147
|
|
|
1053
|
-
const r = await bs(bsArgs, {
|
|
1148
|
+
const r = await bs(bsArgs, {
|
|
1149
|
+
cwd: opts.cwd,
|
|
1150
|
+
env: {
|
|
1151
|
+
[SUBAGENT_DEPTH_ENV]: String(opts.depth),
|
|
1152
|
+
[SUBAGENT_MAX_DEPTH_ENV]: String(opts.maxDepth),
|
|
1153
|
+
},
|
|
1154
|
+
});
|
|
1054
1155
|
if (r.code !== 0) {
|
|
1055
1156
|
return { error: r.stderr || r.stdout || `babysit run failed (exit ${r.code}, no output) — check that \`${BABYSIT_BIN}\` works and ${ROOT} is writable` };
|
|
1056
1157
|
}
|
|
@@ -1065,7 +1166,13 @@ async function spawnSubagent(
|
|
|
1065
1166
|
// below fails and we kill the session, the exit poller does NOT mistake it
|
|
1066
1167
|
// for an un-notified process and fire a spurious process-end notification.
|
|
1067
1168
|
// The success path overwrites this with the full task meta.
|
|
1068
|
-
writeMeta(id, {
|
|
1169
|
+
writeMeta(id, {
|
|
1170
|
+
kind: "subagent",
|
|
1171
|
+
task: opts.task,
|
|
1172
|
+
notified: true,
|
|
1173
|
+
depth: opts.depth,
|
|
1174
|
+
maxDepth: opts.maxDepth,
|
|
1175
|
+
});
|
|
1069
1176
|
|
|
1070
1177
|
// Wait for pi to boot (first JSON event in the log), then inject the task.
|
|
1071
1178
|
await bs(["expect", "-s", id, "--timeout", "30s", '\\{"type"']);
|
|
@@ -1107,6 +1214,8 @@ async function spawnSubagent(
|
|
|
1107
1214
|
task: opts.task,
|
|
1108
1215
|
promptOffset: sent.offset,
|
|
1109
1216
|
model: resolvedModel,
|
|
1217
|
+
depth: opts.depth,
|
|
1218
|
+
maxDepth: opts.maxDepth,
|
|
1110
1219
|
startedAt: Date.now(),
|
|
1111
1220
|
});
|
|
1112
1221
|
return { id, model: resolvedModel };
|
|
@@ -1644,11 +1753,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
1644
1753
|
status?: DisplayStatus;
|
|
1645
1754
|
success?: boolean;
|
|
1646
1755
|
exitCode?: number | null;
|
|
1756
|
+
command?: string;
|
|
1757
|
+
count?: number;
|
|
1647
1758
|
};
|
|
1648
1759
|
const status =
|
|
1649
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}`));
|
|
1650
1767
|
const box = new Box(1, 1, (t) => theme.bg("toolSuccessBg", t));
|
|
1651
|
-
box.addChild(new Text(
|
|
1768
|
+
box.addChild(new Text(header, 0, 0));
|
|
1769
|
+
box.addChild(new Text(renderStatus(status, theme), 0, 0));
|
|
1652
1770
|
box.addChild(new Text(theme.fg("toolOutput", content), 0, 0));
|
|
1653
1771
|
return box;
|
|
1654
1772
|
});
|
|
@@ -1726,7 +1844,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1726
1844
|
"`retryOnWorkerDeath` can retry one idempotent command once. " +
|
|
1727
1845
|
"(2) `profile: \"subagent\"` + `task` — spawn a pi subagent that works on the task in the " +
|
|
1728
1846
|
"background; poll with babysit_check, steer with babysit_send, block with babysit_wait, " +
|
|
1729
|
-
"stop with babysit_kill."
|
|
1847
|
+
"stop with babysit_kill. Subagents cannot recursively spawn more subagents by default; " +
|
|
1848
|
+
"the top-level caller must explicitly raise `maxDepth` when creating the first worker.",
|
|
1730
1849
|
promptSnippet:
|
|
1731
1850
|
"Run any shell command with context-safe captured output; quick commands return metadata, longer ones continue in background",
|
|
1732
1851
|
promptGuidelines: [
|
|
@@ -1736,6 +1855,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1736
1855
|
"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.",
|
|
1737
1856
|
"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 }.",
|
|
1738
1857
|
"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.",
|
|
1858
|
+
"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.",
|
|
1739
1859
|
"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.",
|
|
1740
1860
|
],
|
|
1741
1861
|
parameters: Type.Object({
|
|
@@ -1764,6 +1884,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1764
1884
|
tools: Type.Optional(
|
|
1765
1885
|
Type.Array(Type.String(), { description: "Tool allowlist for the subagent." }),
|
|
1766
1886
|
),
|
|
1887
|
+
maxDepth: Type.Optional(
|
|
1888
|
+
Type.Integer({
|
|
1889
|
+
minimum: 1,
|
|
1890
|
+
description:
|
|
1891
|
+
"Maximum subagent nesting depth. Top-level subagent mode only; default 1 prevents workers from spawning workers. Nested workers inherit this limit and cannot override it.",
|
|
1892
|
+
}),
|
|
1893
|
+
),
|
|
1767
1894
|
agentScope: Type.Optional(
|
|
1768
1895
|
StringEnum(["user", "project", "both"] as const, {
|
|
1769
1896
|
description: "Where to discover named agents. Default 'user'.",
|
|
@@ -1826,6 +1953,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
1826
1953
|
};
|
|
1827
1954
|
}
|
|
1828
1955
|
|
|
1956
|
+
// Compute nesting only for subagent mode. Ordinary command processes remain
|
|
1957
|
+
// available even when the hosting agent is at its subagent depth limit.
|
|
1958
|
+
const nesting = isSubagent
|
|
1959
|
+
? planSubagentSpawn(params.maxDepth)
|
|
1960
|
+
: undefined;
|
|
1961
|
+
if (nesting && !nesting.allowed) {
|
|
1962
|
+
return {
|
|
1963
|
+
content: [{ type: "text", text: nesting.error }],
|
|
1964
|
+
isError: true,
|
|
1965
|
+
details: {},
|
|
1966
|
+
};
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1829
1969
|
// --- process mode ---
|
|
1830
1970
|
if (!isSubagent) {
|
|
1831
1971
|
const spawnOpts: ProcOpts = {
|
|
@@ -1966,12 +2106,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
1966
2106
|
}
|
|
1967
2107
|
}
|
|
1968
2108
|
|
|
2109
|
+
// The branch above guarantees a successful plan in subagent mode.
|
|
2110
|
+
const subagentNesting = nesting as Extract<SubagentSpawnPlan, { allowed: true }>;
|
|
1969
2111
|
const res = await spawnSubagent({
|
|
1970
2112
|
agent,
|
|
1971
2113
|
task: params.task as string,
|
|
1972
2114
|
model: params.model,
|
|
1973
2115
|
tools: params.tools,
|
|
1974
2116
|
cwd: ctx.cwd,
|
|
2117
|
+
depth: subagentNesting.childDepth,
|
|
2118
|
+
maxDepth: subagentNesting.maxDepth,
|
|
1975
2119
|
timeout: params.timeout ?? "15m",
|
|
1976
2120
|
idleTimeout: params.idleTimeout,
|
|
1977
2121
|
});
|
|
@@ -1990,7 +2134,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1990
2134
|
{
|
|
1991
2135
|
type: "text",
|
|
1992
2136
|
text:
|
|
1993
|
-
`Subagent started (id: ${res.id})${agent ? ` [agent: ${agent.name}]` : ""}${res.model ? ` [model: ${res.model}]` : ""}.\n` +
|
|
2137
|
+
`Subagent started (id: ${res.id})${agent ? ` [agent: ${agent.name}]` : ""}${res.model ? ` [model: ${res.model}]` : ""} [depth: ${subagentNesting.childDepth}/${subagentNesting.maxDepth}].\n` +
|
|
1994
2138
|
`Task accepted — running in the background; keep working (do NOT end your turn just to wait for it).\n` +
|
|
1995
2139
|
`Poll: babysit_check { id: "${res.id}" }\n` +
|
|
1996
2140
|
`Wait: babysit_wait { id: "${res.id}" }\n` +
|
|
@@ -2003,14 +2147,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
2003
2147
|
agent: agent?.name,
|
|
2004
2148
|
model: res.model,
|
|
2005
2149
|
task: params.task,
|
|
2150
|
+
depth: subagentNesting.childDepth,
|
|
2151
|
+
maxDepth: subagentNesting.maxDepth,
|
|
2006
2152
|
status: "started" satisfies DisplayStatus,
|
|
2007
2153
|
},
|
|
2008
2154
|
};
|
|
2009
2155
|
},
|
|
2010
|
-
//
|
|
2011
|
-
//
|
|
2012
|
-
renderCall() {
|
|
2013
|
-
|
|
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);
|
|
2014
2169
|
},
|
|
2015
2170
|
renderResult(result, { isPartial }, theme, context) {
|
|
2016
2171
|
const details = (result.details ?? {}) as {
|
|
@@ -2038,7 +2193,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2038
2193
|
: details.kind === "subagent" || content.includes(NOTIFY_MARKER)
|
|
2039
2194
|
? "started"
|
|
2040
2195
|
: "success");
|
|
2041
|
-
const label = renderStatus(status, theme
|
|
2196
|
+
const label = renderStatus(status, theme);
|
|
2042
2197
|
return new Text(content ? `${label}\n${theme.fg("toolOutput", content)}` : label, 0, 0);
|
|
2043
2198
|
},
|
|
2044
2199
|
});
|
|
@@ -2094,9 +2249,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
2094
2249
|
const kind = meta?.kind ?? "process";
|
|
2095
2250
|
const flag = s.note ? ` ⚑ ${s.note}` : "";
|
|
2096
2251
|
const ec = s.exit_code != null ? ` exit=${s.exit_code}` : "";
|
|
2252
|
+
const depth =
|
|
2253
|
+
kind === "subagent" && meta?.depth != null
|
|
2254
|
+
? ` depth=${meta.depth}/${meta.maxDepth ?? "?"}`
|
|
2255
|
+
: "";
|
|
2097
2256
|
const what = (kind === "subagent" ? meta?.task : meta?.command) ?? "";
|
|
2098
2257
|
const preview = what.length > 60 ? `${what.slice(0, 57)}…` : what;
|
|
2099
|
-
return `${s.id} [${kind}] ${s.state}${ec}${flag}${preview ? ` — ${preview}` : ""}`;
|
|
2258
|
+
return `${s.id} [${kind}] ${s.state}${ec}${depth}${flag}${preview ? ` — ${preview}` : ""}`;
|
|
2100
2259
|
});
|
|
2101
2260
|
return { content: [{ type: "text", text: lines.join("\n") }], details: { sessions } };
|
|
2102
2261
|
}
|
|
@@ -2182,6 +2341,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2182
2341
|
|
|
2183
2342
|
const parts: string[] = [];
|
|
2184
2343
|
let header = `[subagent] state=${st.state}`;
|
|
2344
|
+
if (meta.depth != null) header += ` depth=${meta.depth}/${meta.maxDepth ?? "?"}`;
|
|
2185
2345
|
if (st.state === "running") {
|
|
2186
2346
|
const el = elapsedOf(params.id);
|
|
2187
2347
|
if (el) header += ` elapsed=${el}`;
|
|
@@ -2362,6 +2522,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
2362
2522
|
task: params.text,
|
|
2363
2523
|
promptOffset: sent.offset,
|
|
2364
2524
|
model: meta?.model,
|
|
2525
|
+
depth: meta?.depth,
|
|
2526
|
+
maxDepth: meta?.maxDepth,
|
|
2365
2527
|
});
|
|
2366
2528
|
}
|
|
2367
2529
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yusukeshib/pi-babysit",
|
|
3
|
-
"version": "0.3.
|
|
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();
|