@yusukeshib/pi-babysit 0.3.7 → 0.3.9
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 +22 -10
- package/index.ts +391 -61
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ reachable from anywhere (`~/.pi-babysit/<pi-session-id>/`). Two kinds:
|
|
|
32
32
|
|
|
33
33
|
| kind | started by | completion | on completion |
|
|
34
34
|
| ---- | ---------- | ---------- | ------------- |
|
|
35
|
-
| **process** | `babysit_run { command }` | process **exit** | automatic notification message (`triggerTurn`) — the agent may end its turn after starting and is resumed on exit, same contract as the old `process` tool |
|
|
35
|
+
| **process** | `babysit_run { command }` | process **exit** | automatic notification message (`triggerTurn`), batched for all exits observed in the same poll — the agent may end its turn after starting and is resumed on exit, same contract as the old `process` tool |
|
|
36
36
|
| **subagent** | `babysit_run { profile: "subagent", task }` | `agent_end` in the RPC event stream (process stays alive) | none — the agent polls `babysit_check` or blocks on `babysit_wait`; the idle session accepts follow-up tasks |
|
|
37
37
|
|
|
38
38
|
The **profile is a tool parameter, not a separate tool set**: domain knowledge
|
|
@@ -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"` |
|
|
@@ -72,9 +79,12 @@ A minimal widget above the editor shows live counts
|
|
|
72
79
|
`babysit_run`, `babysit_wait`, and automatic completion notifications always
|
|
73
80
|
return lifecycle metadata and the absolute path to the complete `output.log`.
|
|
74
81
|
Explicit run/wait results inline complete output up to 8 KB; unsolicited
|
|
75
|
-
completion notifications use a stricter 2 KB cap
|
|
76
|
-
|
|
77
|
-
|
|
82
|
+
completion notifications use a stricter 2 KB per-process output cap and an 8 KB
|
|
83
|
+
aggregate message cap. Process exits observed in one poll share one message and
|
|
84
|
+
trigger one agent turn. If even the compact summaries and log paths exceed the
|
|
85
|
+
aggregate cap, the largest fitting prefix is delivered and the remainder stays
|
|
86
|
+
pending for the next poll. Larger output stays out of model context. Inspect it
|
|
87
|
+
through the session id without creating another shell session:
|
|
78
88
|
|
|
79
89
|
```text
|
|
80
90
|
babysit_check { id: "cargo-test", lines: 50 }
|
|
@@ -100,9 +110,10 @@ because blindly rerunning an arbitrary command can duplicate side effects.
|
|
|
100
110
|
## How completion detection works
|
|
101
111
|
|
|
102
112
|
- **Process**: a 2.5s poller watches for running→exited transitions and injects
|
|
103
|
-
one `pi.sendMessage(…, { triggerTurn: true, deliverAs: "steer" })`
|
|
104
|
-
|
|
105
|
-
reported by `babysit_wait` suppress the
|
|
113
|
+
one `pi.sendMessage(…, { triggerTurn: true, deliverAs: "steer" })` containing
|
|
114
|
+
every deliverable exit observed in that poll (deduped via `meta/<id>.json`).
|
|
115
|
+
`babysit_kill` and an exit already reported by `babysit_wait` suppress the
|
|
116
|
+
notification.
|
|
106
117
|
- **Subagent**: `babysit_wait` blocks on `babysit expect '"type":"agent_end"'`.
|
|
107
118
|
An `agent_end` whose last message is a **parked** toolResult — a
|
|
108
119
|
`babysit_run { command }` result carrying the `[notify-on-exit]` marker (or
|
|
@@ -126,8 +137,9 @@ rule, so a subagent waiting on a long build is never false-killed.
|
|
|
126
137
|
| `PI_BABYSIT_REAP_AFTER` | `120s` | idle grace before a finished subagent self-exits (`off`/`none`/`0` disables) |
|
|
127
138
|
| `PI_BABYSIT_TAIL_MAX_BYTES` | `8000` | cap for explicit log tails/screens returned by `babysit_check` |
|
|
128
139
|
| `PI_BABYSIT_INLINE_OUTPUT_MAX_BYTES` | `8000` | cap for complete output in explicitly requested run/wait results |
|
|
129
|
-
| `PI_BABYSIT_NOTIFY_OUTPUT_MAX_BYTES` | `2000` |
|
|
130
|
-
| `PI_BABYSIT_NOTIFY_COMMAND_MAX_BYTES` | `240` | cap for
|
|
140
|
+
| `PI_BABYSIT_NOTIFY_OUTPUT_MAX_BYTES` | `2000` | per-process output cap for unsolicited completion notifications (`0` omits all output) |
|
|
141
|
+
| `PI_BABYSIT_NOTIFY_COMMAND_MAX_BYTES` | `240` | cap for each command preview in completion notifications |
|
|
142
|
+
| `PI_BABYSIT_NOTIFY_BATCH_MAX_BYTES` | `8000` | hard cap for one aggregated completion notification |
|
|
131
143
|
| `PI_BABYSIT_ALLOW_BASH` | unset | set to `1` to bypass direct-Bash redirection (emergency escape hatch) |
|
|
132
144
|
|
|
133
145
|
Requires `babysit` 0.13.0 or newer and `pi` on `PATH`. The extension does **not**
|
package/index.ts
CHANGED
|
@@ -52,6 +52,78 @@ 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
|
|
@@ -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");
|
|
@@ -317,6 +395,16 @@ function readMeta(id: string): Meta | null {
|
|
|
317
395
|
}
|
|
318
396
|
}
|
|
319
397
|
|
|
398
|
+
export function shouldDeliverProcessCompletion(
|
|
399
|
+
meta: {
|
|
400
|
+
kind: "process" | "subagent";
|
|
401
|
+
notified?: boolean;
|
|
402
|
+
notificationPaused?: boolean;
|
|
403
|
+
} | null,
|
|
404
|
+
): meta is { kind: "process"; notified?: boolean; notificationPaused?: boolean } {
|
|
405
|
+
return meta?.kind === "process" && !meta.notified && !meta.notificationPaused;
|
|
406
|
+
}
|
|
407
|
+
|
|
320
408
|
const kindOf = (id: string): "process" | "subagent" => readMeta(id)?.kind ?? "process";
|
|
321
409
|
|
|
322
410
|
// Compact elapsed formatting: "42s", "3m12s", "1h04m".
|
|
@@ -437,6 +525,7 @@ const TAIL_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_TAIL_MAX_BYTES", 8_000);
|
|
|
437
525
|
const INLINE_OUTPUT_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_INLINE_OUTPUT_MAX_BYTES", 8_000);
|
|
438
526
|
const NOTIFY_OUTPUT_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_OUTPUT_MAX_BYTES", 2_000);
|
|
439
527
|
const NOTIFY_COMMAND_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_COMMAND_MAX_BYTES", 240);
|
|
528
|
+
const NOTIFY_BATCH_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_BATCH_MAX_BYTES", 8_000);
|
|
440
529
|
const ANSWER_MAX_BYTES = 24_000; // subagent answers / error messages
|
|
441
530
|
|
|
442
531
|
function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
|
|
@@ -560,6 +649,193 @@ export function summarizeNotificationCommand(command: string | undefined): strin
|
|
|
560
649
|
return `${prefix}…`;
|
|
561
650
|
}
|
|
562
651
|
|
|
652
|
+
type CompletionStatus = "success" | "failed" | "terminated";
|
|
653
|
+
|
|
654
|
+
export interface ProcessCompletionNotice {
|
|
655
|
+
id: string;
|
|
656
|
+
exitCode: number | null | undefined;
|
|
657
|
+
success: boolean;
|
|
658
|
+
status: CompletionStatus;
|
|
659
|
+
runtime: string;
|
|
660
|
+
summary: string;
|
|
661
|
+
command: string | undefined;
|
|
662
|
+
logPath: string;
|
|
663
|
+
output: string;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
interface ProcessCompletionMessage {
|
|
667
|
+
customType: "pi-babysit-process-end";
|
|
668
|
+
content: string;
|
|
669
|
+
display: true;
|
|
670
|
+
details: {
|
|
671
|
+
id?: string;
|
|
672
|
+
exitCode?: number | null;
|
|
673
|
+
success: boolean;
|
|
674
|
+
status: CompletionStatus;
|
|
675
|
+
runtime?: string;
|
|
676
|
+
logPath?: string;
|
|
677
|
+
count: number;
|
|
678
|
+
totalCount: number;
|
|
679
|
+
remainingCount: number;
|
|
680
|
+
processes: Array<{
|
|
681
|
+
id: string;
|
|
682
|
+
exitCode: number | null | undefined;
|
|
683
|
+
success: boolean;
|
|
684
|
+
status: CompletionStatus;
|
|
685
|
+
runtime: string;
|
|
686
|
+
logPath: string;
|
|
687
|
+
}>;
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const COMPLETION_FOOTER =
|
|
692
|
+
"Automatic completion notification. Inspect the bounded log with babysit_check only if needed.";
|
|
693
|
+
const AGGREGATE_OUTPUT_OMISSION = "\nOutput omitted from aggregate notification; inspect log.";
|
|
694
|
+
|
|
695
|
+
function truncateUtf8End(value: string, maxBytes: number): string {
|
|
696
|
+
if (maxBytes <= 0) return "";
|
|
697
|
+
const bytes = Buffer.from(value, "utf8");
|
|
698
|
+
if (bytes.length <= maxBytes) return value;
|
|
699
|
+
const suffix = Buffer.from("…", "utf8");
|
|
700
|
+
if (maxBytes <= suffix.length) return ".".repeat(maxBytes);
|
|
701
|
+
const prefix = bytes
|
|
702
|
+
.subarray(0, maxBytes - suffix.length)
|
|
703
|
+
.toString("utf8")
|
|
704
|
+
.replace(/\uFFFD+$/, "");
|
|
705
|
+
return `${prefix}…`;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** Build one bounded message for as many deliverable completions as fit. */
|
|
709
|
+
export function buildProcessCompletionMessage(
|
|
710
|
+
notices: ProcessCompletionNotice[],
|
|
711
|
+
maxBytes = NOTIFY_BATCH_MAX_BYTES,
|
|
712
|
+
): ProcessCompletionMessage {
|
|
713
|
+
if (notices.length === 0) throw new Error("At least one completion notice is required.");
|
|
714
|
+
|
|
715
|
+
const totalCount = notices.length;
|
|
716
|
+
const footer = `\n\n${COMPLETION_FOOTER}`;
|
|
717
|
+
const header = (count: number) =>
|
|
718
|
+
totalCount === 1
|
|
719
|
+
? ""
|
|
720
|
+
: count === totalCount
|
|
721
|
+
? `${count} processes completed:\n\n`
|
|
722
|
+
: `${count} of ${totalCount} processes completed:\n\n`;
|
|
723
|
+
const deferred = (count: number) =>
|
|
724
|
+
count < totalCount
|
|
725
|
+
? `\n\n${totalCount - count} completion${totalCount - count === 1 ? "" : "s"} deferred to the next poll.`
|
|
726
|
+
: "";
|
|
727
|
+
const renderCompact = (batch: ProcessCompletionNotice[]) =>
|
|
728
|
+
header(batch.length) +
|
|
729
|
+
batch.map((notice) => `${notice.summary}\nLog: ${notice.logPath}`).join("\n\n") +
|
|
730
|
+
deferred(batch.length) +
|
|
731
|
+
footer;
|
|
732
|
+
|
|
733
|
+
// If every summary and log path cannot fit, notify the largest fitting prefix
|
|
734
|
+
// and leave the rest unacknowledged for a later poll. Always make progress for
|
|
735
|
+
// pathological single ids/paths by delivering one UTF-8-truncated entry.
|
|
736
|
+
let batch = notices;
|
|
737
|
+
if (Buffer.byteLength(renderCompact(batch), "utf8") > maxBytes) {
|
|
738
|
+
let low = 1;
|
|
739
|
+
let high = notices.length - 1;
|
|
740
|
+
let fittedCount = 0;
|
|
741
|
+
while (low <= high) {
|
|
742
|
+
const mid = Math.floor((low + high) / 2);
|
|
743
|
+
if (Buffer.byteLength(renderCompact(notices.slice(0, mid)), "utf8") <= maxBytes) {
|
|
744
|
+
fittedCount = mid;
|
|
745
|
+
low = mid + 1;
|
|
746
|
+
} else {
|
|
747
|
+
high = mid - 1;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
batch = fittedCount > 0 ? notices.slice(0, fittedCount) : [notices[0]];
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const blockBase = (notice: ProcessCompletionNotice) =>
|
|
754
|
+
`${notice.summary}\nCommand: ${summarizeNotificationCommand(notice.command)}\nLog: ${notice.logPath}`;
|
|
755
|
+
const outputs = batch.map((notice) =>
|
|
756
|
+
notice.output.startsWith("\n\nOutput:") ? AGGREGATE_OUTPUT_OMISSION : notice.output,
|
|
757
|
+
);
|
|
758
|
+
const renderDetailed = () =>
|
|
759
|
+
header(batch.length) +
|
|
760
|
+
batch.map((notice, i) => blockBase(notice) + outputs[i]).join("\n\n") +
|
|
761
|
+
deferred(batch.length) +
|
|
762
|
+
footer;
|
|
763
|
+
|
|
764
|
+
let content = renderDetailed();
|
|
765
|
+
let contentBytes = Buffer.byteLength(content, "utf8");
|
|
766
|
+
if (contentBytes <= maxBytes) {
|
|
767
|
+
// Spend the remaining aggregate budget on complete inline outputs without
|
|
768
|
+
// repeatedly rebuilding the entire batch for every candidate.
|
|
769
|
+
for (let i = 0; i < batch.length; i++) {
|
|
770
|
+
if (!batch[i].output.startsWith("\n\nOutput:")) continue;
|
|
771
|
+
const delta =
|
|
772
|
+
Buffer.byteLength(batch[i].output, "utf8") - Buffer.byteLength(outputs[i], "utf8");
|
|
773
|
+
if (contentBytes + delta > maxBytes) continue;
|
|
774
|
+
outputs[i] = batch[i].output;
|
|
775
|
+
contentBytes += delta;
|
|
776
|
+
}
|
|
777
|
+
content = renderDetailed();
|
|
778
|
+
} else {
|
|
779
|
+
content = truncateUtf8End(renderCompact(batch), maxBytes);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const statuses = new Set(batch.map((notice) => notice.status));
|
|
783
|
+
const status: CompletionStatus = statuses.has("failed")
|
|
784
|
+
? "failed"
|
|
785
|
+
: statuses.has("terminated")
|
|
786
|
+
? "terminated"
|
|
787
|
+
: "success";
|
|
788
|
+
const processes = batch.map((notice) => ({
|
|
789
|
+
id: notice.id,
|
|
790
|
+
exitCode: notice.exitCode,
|
|
791
|
+
success: notice.success,
|
|
792
|
+
status: notice.status,
|
|
793
|
+
runtime: notice.runtime,
|
|
794
|
+
logPath: notice.logPath,
|
|
795
|
+
}));
|
|
796
|
+
const single = totalCount === 1 ? batch[0] : undefined;
|
|
797
|
+
return {
|
|
798
|
+
customType: "pi-babysit-process-end",
|
|
799
|
+
content,
|
|
800
|
+
display: true,
|
|
801
|
+
details: {
|
|
802
|
+
id: single?.id,
|
|
803
|
+
exitCode: single?.exitCode,
|
|
804
|
+
success: batch.every((notice) => notice.success),
|
|
805
|
+
status,
|
|
806
|
+
runtime: single?.runtime,
|
|
807
|
+
logPath: single?.logPath,
|
|
808
|
+
count: batch.length,
|
|
809
|
+
totalCount,
|
|
810
|
+
remainingCount: totalCount - batch.length,
|
|
811
|
+
processes,
|
|
812
|
+
},
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** Send once and only acknowledge notices represented in the accepted batch. */
|
|
817
|
+
export function deliverProcessCompletionMessage(
|
|
818
|
+
notices: ProcessCompletionNotice[],
|
|
819
|
+
send: (
|
|
820
|
+
message: ProcessCompletionMessage,
|
|
821
|
+
options: { triggerTurn: true; deliverAs: "steer" },
|
|
822
|
+
) => void,
|
|
823
|
+
onSent: (notice: ProcessCompletionNotice) => void,
|
|
824
|
+
): boolean {
|
|
825
|
+
if (notices.length === 0) return false;
|
|
826
|
+
let message: ProcessCompletionMessage;
|
|
827
|
+
try {
|
|
828
|
+
message = buildProcessCompletionMessage(notices);
|
|
829
|
+
send(message, { triggerTurn: true, deliverAs: "steer" });
|
|
830
|
+
} catch {
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
const deliveredIds = new Set(message.details.processes.map(({ id }) => id));
|
|
834
|
+
for (const notice of notices) {
|
|
835
|
+
if (deliveredIds.has(notice.id)) onSent(notice);
|
|
836
|
+
}
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
563
839
|
|
|
564
840
|
// ---------------------------------------------------------------------------
|
|
565
841
|
// parked-turn detection (shared rule with self-reap.ts)
|
|
@@ -805,6 +1081,8 @@ interface SubagentOpts {
|
|
|
805
1081
|
model?: string;
|
|
806
1082
|
tools?: string[];
|
|
807
1083
|
cwd: string;
|
|
1084
|
+
depth: number;
|
|
1085
|
+
maxDepth: number;
|
|
808
1086
|
// Idle-timeout is OFF by default: an RPC-mode pi is silent while it works,
|
|
809
1087
|
// so idle detection would false-kill a busy subagent. The absolute timeout
|
|
810
1088
|
// is the safety valve instead.
|
|
@@ -852,7 +1130,13 @@ async function spawnSubagent(
|
|
|
852
1130
|
}
|
|
853
1131
|
bsArgs.push("--", PI_BIN, ...piArgs);
|
|
854
1132
|
|
|
855
|
-
const r = await bs(bsArgs, {
|
|
1133
|
+
const r = await bs(bsArgs, {
|
|
1134
|
+
cwd: opts.cwd,
|
|
1135
|
+
env: {
|
|
1136
|
+
[SUBAGENT_DEPTH_ENV]: String(opts.depth),
|
|
1137
|
+
[SUBAGENT_MAX_DEPTH_ENV]: String(opts.maxDepth),
|
|
1138
|
+
},
|
|
1139
|
+
});
|
|
856
1140
|
if (r.code !== 0) {
|
|
857
1141
|
return { error: r.stderr || r.stdout || `babysit run failed (exit ${r.code}, no output) — check that \`${BABYSIT_BIN}\` works and ${ROOT} is writable` };
|
|
858
1142
|
}
|
|
@@ -867,7 +1151,13 @@ async function spawnSubagent(
|
|
|
867
1151
|
// below fails and we kill the session, the exit poller does NOT mistake it
|
|
868
1152
|
// for an un-notified process and fire a spurious process-end notification.
|
|
869
1153
|
// The success path overwrites this with the full task meta.
|
|
870
|
-
writeMeta(id, {
|
|
1154
|
+
writeMeta(id, {
|
|
1155
|
+
kind: "subagent",
|
|
1156
|
+
task: opts.task,
|
|
1157
|
+
notified: true,
|
|
1158
|
+
depth: opts.depth,
|
|
1159
|
+
maxDepth: opts.maxDepth,
|
|
1160
|
+
});
|
|
871
1161
|
|
|
872
1162
|
// Wait for pi to boot (first JSON event in the log), then inject the task.
|
|
873
1163
|
await bs(["expect", "-s", id, "--timeout", "30s", '\\{"type"']);
|
|
@@ -909,6 +1199,8 @@ async function spawnSubagent(
|
|
|
909
1199
|
task: opts.task,
|
|
910
1200
|
promptOffset: sent.offset,
|
|
911
1201
|
model: resolvedModel,
|
|
1202
|
+
depth: opts.depth,
|
|
1203
|
+
maxDepth: opts.maxDepth,
|
|
912
1204
|
startedAt: Date.now(),
|
|
913
1205
|
});
|
|
914
1206
|
return { id, model: resolvedModel };
|
|
@@ -1289,78 +1581,81 @@ export default function (pi: ExtensionAPI) {
|
|
|
1289
1581
|
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
1290
1582
|
|
|
1291
1583
|
// Exit notifications for kind=process sessions: the poller detects
|
|
1292
|
-
// running→exited transitions and injects ONE message (triggerTurn)
|
|
1293
|
-
//
|
|
1294
|
-
//
|
|
1295
|
-
// reported by babysit_wait are
|
|
1584
|
+
// running→exited transitions and injects ONE message (triggerTurn) for all
|
|
1585
|
+
// processes that became deliverable in the same poll. This resumes an agent
|
|
1586
|
+
// that ended its turn after babysit_run without spending one turn per exit.
|
|
1587
|
+
// Kills via babysit_kill and exits already reported by babysit_wait are
|
|
1588
|
+
// suppressed via meta.notified.
|
|
1296
1589
|
async function notifyEndedProcesses(): Promise<void> {
|
|
1297
1590
|
const { sessions } = await listSessions();
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1591
|
+
const ready: Array<{ session: BsSession; meta: Meta }> = [];
|
|
1592
|
+
for (const session of sessions) {
|
|
1593
|
+
if (session.state === "running") continue;
|
|
1594
|
+
const meta = readMeta(session.id);
|
|
1595
|
+
if (!shouldDeliverProcessCompletion(meta)) continue;
|
|
1302
1596
|
// Delay delivery by one poll interval. This gives an agent that chose
|
|
1303
1597
|
// babysit_wait immediately after babysit_run enough time to claim the
|
|
1304
1598
|
// completion and suppress the otherwise duplicate automatic message.
|
|
1305
1599
|
if (!meta.completionObservedAt) {
|
|
1306
1600
|
meta.completionObservedAt = Date.now();
|
|
1307
|
-
writeMeta(
|
|
1601
|
+
writeMeta(session.id, meta);
|
|
1308
1602
|
continue;
|
|
1309
1603
|
}
|
|
1310
1604
|
if (Date.now() - meta.completionObservedAt < POLL_MS) continue;
|
|
1311
|
-
|
|
1312
|
-
|
|
1605
|
+
ready.push({ session, meta });
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
const prepared: ProcessCompletionNotice[] = [];
|
|
1609
|
+
for (const { session, meta } of ready) {
|
|
1610
|
+
const ok = session.exit_code === 0;
|
|
1611
|
+
const status: CompletionStatus = ok
|
|
1313
1612
|
? "success"
|
|
1314
|
-
:
|
|
1613
|
+
: session.state === "dead" || session.exit_code == null
|
|
1315
1614
|
? "terminated"
|
|
1316
1615
|
: "failed";
|
|
1317
|
-
const output = await inlineOutput(
|
|
1616
|
+
const output = await inlineOutput(session.id, session, NOTIFY_OUTPUT_MAX_BYTES);
|
|
1318
1617
|
const runtime = meta.startedAt
|
|
1319
1618
|
? `${Math.round((Date.now() - meta.startedAt) / 1000)}s`
|
|
1320
1619
|
: "?";
|
|
1321
1620
|
const summary = ok
|
|
1322
|
-
? `Process "${
|
|
1323
|
-
:
|
|
1324
|
-
? `Process "${
|
|
1325
|
-
: `Process "${
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
}
|
|
1337
|
-
try {
|
|
1338
|
-
pi.sendMessage(
|
|
1339
|
-
{
|
|
1340
|
-
customType: "pi-babysit-process-end",
|
|
1341
|
-
content:
|
|
1342
|
-
`${summary}\nCommand: ${summarizeNotificationCommand(current.command)}\nLog: ${logPath(s.id)}${output}` +
|
|
1343
|
-
"\n\nAutomatic completion notification. Inspect the bounded log with babysit_check only if needed.",
|
|
1344
|
-
display: true,
|
|
1345
|
-
details: {
|
|
1346
|
-
id: s.id,
|
|
1347
|
-
exitCode: s.exit_code,
|
|
1348
|
-
success: ok,
|
|
1349
|
-
status,
|
|
1350
|
-
runtime,
|
|
1351
|
-
logPath: logPath(s.id),
|
|
1352
|
-
},
|
|
1353
|
-
},
|
|
1354
|
-
{ triggerTurn: true, deliverAs: "steer" },
|
|
1355
|
-
);
|
|
1356
|
-
} catch {
|
|
1357
|
-
// Leave it pending so the next poll can retry delivery.
|
|
1358
|
-
continue;
|
|
1359
|
-
}
|
|
1360
|
-
current.notified = true;
|
|
1361
|
-
delete current.notificationPaused;
|
|
1362
|
-
writeMeta(s.id, current);
|
|
1621
|
+
? `Process "${session.id}" completed successfully after ${runtime}.`
|
|
1622
|
+
: session.state === "dead" || session.exit_code == null
|
|
1623
|
+
? `Process "${session.id}" was terminated after ${runtime}.`
|
|
1624
|
+
: `Process "${session.id}" exited with code ${session.exit_code} after ${runtime}.`;
|
|
1625
|
+
prepared.push({
|
|
1626
|
+
id: session.id,
|
|
1627
|
+
exitCode: session.exit_code,
|
|
1628
|
+
success: ok,
|
|
1629
|
+
status,
|
|
1630
|
+
runtime,
|
|
1631
|
+
summary,
|
|
1632
|
+
command: meta.command,
|
|
1633
|
+
logPath: logPath(session.id),
|
|
1634
|
+
output,
|
|
1635
|
+
});
|
|
1363
1636
|
}
|
|
1637
|
+
|
|
1638
|
+
// Output loading above is asynchronous. Re-read every candidate together
|
|
1639
|
+
// immediately before the single send so a concurrent kill/wait cannot claim
|
|
1640
|
+
// an early candidate while a later candidate's output is being loaded.
|
|
1641
|
+
const metadataById = new Map<string, Meta>();
|
|
1642
|
+
const notices = prepared.flatMap((notice) => {
|
|
1643
|
+
const current = readMeta(notice.id);
|
|
1644
|
+
if (!shouldDeliverProcessCompletion(current)) return [];
|
|
1645
|
+
metadataById.set(notice.id, current);
|
|
1646
|
+
return [{ ...notice, command: current.command }];
|
|
1647
|
+
});
|
|
1648
|
+
deliverProcessCompletionMessage(
|
|
1649
|
+
notices,
|
|
1650
|
+
(message, options) => pi.sendMessage(message, options),
|
|
1651
|
+
(notice) => {
|
|
1652
|
+
const meta = metadataById.get(notice.id);
|
|
1653
|
+
if (!meta) return;
|
|
1654
|
+
meta.notified = true;
|
|
1655
|
+
delete meta.notificationPaused;
|
|
1656
|
+
writeMeta(notice.id, meta);
|
|
1657
|
+
},
|
|
1658
|
+
);
|
|
1364
1659
|
}
|
|
1365
1660
|
|
|
1366
1661
|
const refreshWidget = async (ctx: ExtensionContext) => {
|
|
@@ -1525,7 +1820,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1525
1820
|
"`retryOnWorkerDeath` can retry one idempotent command once. " +
|
|
1526
1821
|
"(2) `profile: \"subagent\"` + `task` — spawn a pi subagent that works on the task in the " +
|
|
1527
1822
|
"background; poll with babysit_check, steer with babysit_send, block with babysit_wait, " +
|
|
1528
|
-
"stop with babysit_kill."
|
|
1823
|
+
"stop with babysit_kill. Subagents cannot recursively spawn more subagents by default; " +
|
|
1824
|
+
"the top-level caller must explicitly raise `maxDepth` when creating the first worker.",
|
|
1529
1825
|
promptSnippet:
|
|
1530
1826
|
"Run any shell command with context-safe captured output; quick commands return metadata, longer ones continue in background",
|
|
1531
1827
|
promptGuidelines: [
|
|
@@ -1535,6 +1831,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1535
1831
|
"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.",
|
|
1536
1832
|
"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 }.",
|
|
1537
1833
|
"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.",
|
|
1834
|
+
"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.",
|
|
1538
1835
|
"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.",
|
|
1539
1836
|
],
|
|
1540
1837
|
parameters: Type.Object({
|
|
@@ -1563,6 +1860,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1563
1860
|
tools: Type.Optional(
|
|
1564
1861
|
Type.Array(Type.String(), { description: "Tool allowlist for the subagent." }),
|
|
1565
1862
|
),
|
|
1863
|
+
maxDepth: Type.Optional(
|
|
1864
|
+
Type.Integer({
|
|
1865
|
+
minimum: 1,
|
|
1866
|
+
description:
|
|
1867
|
+
"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.",
|
|
1868
|
+
}),
|
|
1869
|
+
),
|
|
1566
1870
|
agentScope: Type.Optional(
|
|
1567
1871
|
StringEnum(["user", "project", "both"] as const, {
|
|
1568
1872
|
description: "Where to discover named agents. Default 'user'.",
|
|
@@ -1625,6 +1929,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
1625
1929
|
};
|
|
1626
1930
|
}
|
|
1627
1931
|
|
|
1932
|
+
// Compute nesting only for subagent mode. Ordinary command processes remain
|
|
1933
|
+
// available even when the hosting agent is at its subagent depth limit.
|
|
1934
|
+
const nesting = isSubagent
|
|
1935
|
+
? planSubagentSpawn(params.maxDepth)
|
|
1936
|
+
: undefined;
|
|
1937
|
+
if (nesting && !nesting.allowed) {
|
|
1938
|
+
return {
|
|
1939
|
+
content: [{ type: "text", text: nesting.error }],
|
|
1940
|
+
isError: true,
|
|
1941
|
+
details: {},
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1628
1945
|
// --- process mode ---
|
|
1629
1946
|
if (!isSubagent) {
|
|
1630
1947
|
const spawnOpts: ProcOpts = {
|
|
@@ -1765,12 +2082,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
1765
2082
|
}
|
|
1766
2083
|
}
|
|
1767
2084
|
|
|
2085
|
+
// The branch above guarantees a successful plan in subagent mode.
|
|
2086
|
+
const subagentNesting = nesting as Extract<SubagentSpawnPlan, { allowed: true }>;
|
|
1768
2087
|
const res = await spawnSubagent({
|
|
1769
2088
|
agent,
|
|
1770
2089
|
task: params.task as string,
|
|
1771
2090
|
model: params.model,
|
|
1772
2091
|
tools: params.tools,
|
|
1773
2092
|
cwd: ctx.cwd,
|
|
2093
|
+
depth: subagentNesting.childDepth,
|
|
2094
|
+
maxDepth: subagentNesting.maxDepth,
|
|
1774
2095
|
timeout: params.timeout ?? "15m",
|
|
1775
2096
|
idleTimeout: params.idleTimeout,
|
|
1776
2097
|
});
|
|
@@ -1789,7 +2110,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1789
2110
|
{
|
|
1790
2111
|
type: "text",
|
|
1791
2112
|
text:
|
|
1792
|
-
`Subagent started (id: ${res.id})${agent ? ` [agent: ${agent.name}]` : ""}${res.model ? ` [model: ${res.model}]` : ""}.\n` +
|
|
2113
|
+
`Subagent started (id: ${res.id})${agent ? ` [agent: ${agent.name}]` : ""}${res.model ? ` [model: ${res.model}]` : ""} [depth: ${subagentNesting.childDepth}/${subagentNesting.maxDepth}].\n` +
|
|
1793
2114
|
`Task accepted — running in the background; keep working (do NOT end your turn just to wait for it).\n` +
|
|
1794
2115
|
`Poll: babysit_check { id: "${res.id}" }\n` +
|
|
1795
2116
|
`Wait: babysit_wait { id: "${res.id}" }\n` +
|
|
@@ -1802,6 +2123,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1802
2123
|
agent: agent?.name,
|
|
1803
2124
|
model: res.model,
|
|
1804
2125
|
task: params.task,
|
|
2126
|
+
depth: subagentNesting.childDepth,
|
|
2127
|
+
maxDepth: subagentNesting.maxDepth,
|
|
1805
2128
|
status: "started" satisfies DisplayStatus,
|
|
1806
2129
|
},
|
|
1807
2130
|
};
|
|
@@ -1893,9 +2216,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1893
2216
|
const kind = meta?.kind ?? "process";
|
|
1894
2217
|
const flag = s.note ? ` ⚑ ${s.note}` : "";
|
|
1895
2218
|
const ec = s.exit_code != null ? ` exit=${s.exit_code}` : "";
|
|
2219
|
+
const depth =
|
|
2220
|
+
kind === "subagent" && meta?.depth != null
|
|
2221
|
+
? ` depth=${meta.depth}/${meta.maxDepth ?? "?"}`
|
|
2222
|
+
: "";
|
|
1896
2223
|
const what = (kind === "subagent" ? meta?.task : meta?.command) ?? "";
|
|
1897
2224
|
const preview = what.length > 60 ? `${what.slice(0, 57)}…` : what;
|
|
1898
|
-
return `${s.id} [${kind}] ${s.state}${ec}${flag}${preview ? ` — ${preview}` : ""}`;
|
|
2225
|
+
return `${s.id} [${kind}] ${s.state}${ec}${depth}${flag}${preview ? ` — ${preview}` : ""}`;
|
|
1899
2226
|
});
|
|
1900
2227
|
return { content: [{ type: "text", text: lines.join("\n") }], details: { sessions } };
|
|
1901
2228
|
}
|
|
@@ -1981,6 +2308,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1981
2308
|
|
|
1982
2309
|
const parts: string[] = [];
|
|
1983
2310
|
let header = `[subagent] state=${st.state}`;
|
|
2311
|
+
if (meta.depth != null) header += ` depth=${meta.depth}/${meta.maxDepth ?? "?"}`;
|
|
1984
2312
|
if (st.state === "running") {
|
|
1985
2313
|
const el = elapsedOf(params.id);
|
|
1986
2314
|
if (el) header += ` elapsed=${el}`;
|
|
@@ -2161,6 +2489,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
2161
2489
|
task: params.text,
|
|
2162
2490
|
promptOffset: sent.offset,
|
|
2163
2491
|
model: meta?.model,
|
|
2492
|
+
depth: meta?.depth,
|
|
2493
|
+
maxDepth: meta?.maxDepth,
|
|
2164
2494
|
});
|
|
2165
2495
|
}
|
|
2166
2496
|
return {
|