@yusukeshib/pi-babysit 0.3.12 → 0.3.13
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 +11 -8
- package/agents.ts +23 -9
- package/index.ts +474 -196
- package/package.json +1 -1
- package/self-reap.ts +30 -23
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ reachable from anywhere (`~/.pi-babysit/<pi-session-id>/`). Two kinds:
|
|
|
33
33
|
| kind | started by | completion | on completion |
|
|
34
34
|
| ---- | ---------- | ---------- | ------------- |
|
|
35
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
|
-
| **subagent** | `babysit_run { profile: "subagent", task }` | `
|
|
36
|
+
| **subagent** | `babysit_run { profile: "subagent", task }` | `agent_settled` 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
|
|
39
39
|
(RPC bookkeeping, per-task byte offsets, parked-turn detection, PTY-safe
|
|
@@ -130,13 +130,16 @@ because blindly rerunning an arbitrary command can duplicate side effects.
|
|
|
130
130
|
poller and receiving a duplicate completion.
|
|
131
131
|
`babysit_kill` and an exit already reported by `babysit_wait` suppress the
|
|
132
132
|
notification.
|
|
133
|
-
- **Subagent**: `babysit_wait` blocks on `babysit expect '"type":"
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
133
|
+
- **Subagent**: `babysit_wait` blocks on `babysit expect '"type":"agent_settled"'`.
|
|
134
|
+
Unlike `agent_end`, `agent_settled` cannot precede an automatic retry,
|
|
135
|
+
compaction retry, or queued continuation. A settled run containing a
|
|
136
|
+
**parked** toolResult — a `babysit_run { command }` result carrying the
|
|
137
|
+
`[notify-on-exit]` marker (or the legacy `process` tool) — only means "turn
|
|
138
|
+
parked awaiting a process-exit notification; pi resumes on its own", so the
|
|
139
|
+
wait continues. The marker-bearing result may be followed by a short
|
|
140
|
+
assistant note without defeating parked detection. Per-task byte offsets
|
|
141
|
+
scope check/wait to the CURRENT task, and appended log bytes are parsed
|
|
142
|
+
incrementally, which keeps follow-up tasks efficient.
|
|
140
143
|
|
|
141
144
|
Subagents load `self-reap.ts`, which exits an idle finished subagent after a
|
|
142
145
|
grace window (`PI_BABYSIT_REAP_AFTER`, default 120s) using the same parked-turn
|
package/agents.ts
CHANGED
|
@@ -56,20 +56,34 @@ function loadAgentsFromDir(
|
|
|
56
56
|
continue;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
let parsed: ReturnType<typeof parseFrontmatter<Record<string, unknown>>>;
|
|
60
|
+
try {
|
|
61
|
+
parsed = parseFrontmatter<Record<string, unknown>>(content);
|
|
62
|
+
} catch {
|
|
63
|
+
// One malformed definition must not prevent every other named agent
|
|
64
|
+
// from being discovered.
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const { frontmatter, body } = parsed;
|
|
68
|
+
if (
|
|
69
|
+
typeof frontmatter.name !== "string" ||
|
|
70
|
+
typeof frontmatter.description !== "string"
|
|
71
|
+
) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
62
74
|
|
|
63
|
-
const tools = frontmatter.tools
|
|
64
|
-
|
|
65
|
-
.
|
|
66
|
-
|
|
75
|
+
const tools = Array.isArray(frontmatter.tools)
|
|
76
|
+
? frontmatter.tools.filter((tool): tool is string => typeof tool === "string")
|
|
77
|
+
: typeof frontmatter.tools === "string"
|
|
78
|
+
? frontmatter.tools.split(",")
|
|
79
|
+
: [];
|
|
80
|
+
const normalizedTools = tools.map((tool) => tool.trim()).filter(Boolean);
|
|
67
81
|
|
|
68
82
|
agents.push({
|
|
69
83
|
name: frontmatter.name,
|
|
70
84
|
description: frontmatter.description,
|
|
71
|
-
tools:
|
|
72
|
-
model: frontmatter.model,
|
|
85
|
+
tools: normalizedTools.length > 0 ? normalizedTools : undefined,
|
|
86
|
+
model: typeof frontmatter.model === "string" ? frontmatter.model : undefined,
|
|
73
87
|
systemPrompt: body,
|
|
74
88
|
source,
|
|
75
89
|
filePath,
|
package/index.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* kind=subagent `babysit_run { profile: "subagent", task }` — a long-lived
|
|
19
19
|
* `pi --mode rpc` worker. Tasks are injected as RPC `prompt`
|
|
20
20
|
* commands over stdin, completion is detected from the JSONL
|
|
21
|
-
* event stream (`
|
|
21
|
+
* event stream (`agent_settled`), NOT process exit; the session
|
|
22
22
|
* stays alive for cheap follow-up tasks. Same design as the
|
|
23
23
|
* old pi-subagent extension.
|
|
24
24
|
*
|
|
@@ -132,7 +132,7 @@ export function planSubagentSpawn(
|
|
|
132
132
|
|
|
133
133
|
// Marker embedded in babysit_run's tool RESULT text for kind=process runs.
|
|
134
134
|
// It is how "the turn parked awaiting a process-exit notification" is told
|
|
135
|
-
// apart from any other turn end (see
|
|
135
|
+
// apart from any other turn end (see isParkedMessages / self-reap.ts).
|
|
136
136
|
export const NOTIFY_MARKER = "[notify-on-exit]";
|
|
137
137
|
|
|
138
138
|
// Human-readable view for the compact subagent JSONL stream when a human
|
|
@@ -302,22 +302,11 @@ async function listSessions(): Promise<{ sessions: BsSession[]; error?: string }
|
|
|
302
302
|
}
|
|
303
303
|
|
|
304
304
|
async function statusOf(id: string): Promise<BsSession | null> {
|
|
305
|
-
// `
|
|
306
|
-
// `
|
|
307
|
-
|
|
308
|
-
if (r.code !== 0) return null;
|
|
305
|
+
// `list --json` already carries every lifecycle field used by the extension,
|
|
306
|
+
// including `alive` and `note`. Using it directly avoids the old
|
|
307
|
+
// status-then-list pair (two CLI subprocesses for every status lookup).
|
|
309
308
|
try {
|
|
310
|
-
|
|
311
|
-
const inner = parsed.status ?? parsed;
|
|
312
|
-
// `note` and `alive` live only in `list --json`, so fold them in from there
|
|
313
|
-
// (alive is what lets us detect a crashed-but-"running" worker).
|
|
314
|
-
const listed = (await listSessions()).sessions.find((s) => s.id === id);
|
|
315
|
-
return normalizeSession({
|
|
316
|
-
id: parsed.session ?? id,
|
|
317
|
-
note: listed?.note ?? null,
|
|
318
|
-
alive: listed?.alive,
|
|
319
|
-
...inner,
|
|
320
|
-
});
|
|
309
|
+
return (await listSessions()).sessions.find((session) => session.id === id) ?? null;
|
|
321
310
|
} catch {
|
|
322
311
|
return null;
|
|
323
312
|
}
|
|
@@ -380,6 +369,8 @@ interface Meta {
|
|
|
380
369
|
task?: string;
|
|
381
370
|
promptOffset?: number;
|
|
382
371
|
model?: string;
|
|
372
|
+
tools?: string[];
|
|
373
|
+
messageTempDirs?: Array<{ dir: string; afterAgentEnd: number }>;
|
|
383
374
|
depth?: number;
|
|
384
375
|
maxDepth?: number;
|
|
385
376
|
}
|
|
@@ -404,13 +395,28 @@ function readMeta(id: string): Meta | null {
|
|
|
404
395
|
}
|
|
405
396
|
}
|
|
406
397
|
|
|
398
|
+
function cleanupMessageTempDirs(id: string, meta: Meta, agentEnds: number): void {
|
|
399
|
+
if (!meta.messageTempDirs?.length) return;
|
|
400
|
+
const keep: NonNullable<Meta["messageTempDirs"]> = [];
|
|
401
|
+
for (const entry of meta.messageTempDirs) {
|
|
402
|
+
if (entry.afterAgentEnd > agentEnds) {
|
|
403
|
+
keep.push(entry);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
fs.rmSync(entry.dir, { recursive: true, force: true });
|
|
408
|
+
} catch {
|
|
409
|
+
/* best-effort */
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (keep.length > 0) meta.messageTempDirs = keep;
|
|
413
|
+
else delete meta.messageTempDirs;
|
|
414
|
+
writeMeta(id, meta);
|
|
415
|
+
}
|
|
416
|
+
|
|
407
417
|
export function shouldDeliverProcessCompletion(
|
|
408
|
-
meta:
|
|
409
|
-
|
|
410
|
-
notified?: boolean;
|
|
411
|
-
notificationPaused?: boolean;
|
|
412
|
-
} | null,
|
|
413
|
-
): meta is { kind: "process"; notified?: boolean; notificationPaused?: boolean } {
|
|
418
|
+
meta: Meta | null,
|
|
419
|
+
): meta is Meta & { kind: "process" } {
|
|
414
420
|
return meta?.kind === "process" && !meta.notified && !meta.notificationPaused;
|
|
415
421
|
}
|
|
416
422
|
|
|
@@ -418,6 +424,23 @@ export function shouldDeferCompletionNotification(agentIsIdle: boolean): boolean
|
|
|
418
424
|
return !agentIsIdle;
|
|
419
425
|
}
|
|
420
426
|
|
|
427
|
+
export function shouldKeepPolling(
|
|
428
|
+
sessions: Array<{ id: string; state: string }>,
|
|
429
|
+
metaFor: (id: string) => Meta | null,
|
|
430
|
+
): boolean {
|
|
431
|
+
return sessions.some(
|
|
432
|
+
(session) =>
|
|
433
|
+
session.state === "running" || shouldDeliverProcessCompletion(metaFor(session.id)),
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function shouldKeepPollingAfterList(
|
|
438
|
+
listed: { sessions: Array<{ id: string; state: string }>; error?: string },
|
|
439
|
+
metaFor: (id: string) => Meta | null,
|
|
440
|
+
): boolean {
|
|
441
|
+
return Boolean(listed.error) || shouldKeepPolling(listed.sessions, metaFor);
|
|
442
|
+
}
|
|
443
|
+
|
|
421
444
|
const kindOf = (id: string): "process" | "subagent" => readMeta(id)?.kind ?? "process";
|
|
422
445
|
|
|
423
446
|
// Compact elapsed formatting: "42s", "3m12s", "1h04m".
|
|
@@ -455,6 +478,44 @@ async function sendRpc(
|
|
|
455
478
|
}
|
|
456
479
|
}
|
|
457
480
|
|
|
481
|
+
export function parseRpcResponseBytes(
|
|
482
|
+
bytes: Buffer,
|
|
483
|
+
since: number,
|
|
484
|
+
command: string,
|
|
485
|
+
):
|
|
486
|
+
| { ok: true; data?: Record<string, unknown>; offset: number }
|
|
487
|
+
| { ok: false; error: string } {
|
|
488
|
+
let start = 0;
|
|
489
|
+
while (start < bytes.length) {
|
|
490
|
+
const newline = bytes.indexOf(0x0a, start);
|
|
491
|
+
const end = newline < 0 ? bytes.length : newline + 1;
|
|
492
|
+
const line = bytes
|
|
493
|
+
.subarray(start, newline < 0 ? end : newline)
|
|
494
|
+
.toString("utf8")
|
|
495
|
+
.replace(/\r$/, "")
|
|
496
|
+
.trim();
|
|
497
|
+
if (line.startsWith("{")) {
|
|
498
|
+
try {
|
|
499
|
+
const event = JSON.parse(line);
|
|
500
|
+
if (event.type === "response" && event.command === command) {
|
|
501
|
+
if (event.success === false) {
|
|
502
|
+
return { ok: false, error: clip(String(event.error ?? `${command} failed`)) };
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
ok: true,
|
|
506
|
+
data: event.data as Record<string, unknown> | undefined,
|
|
507
|
+
offset: since + end,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
} catch {
|
|
511
|
+
/* partial line */
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
start = end;
|
|
515
|
+
}
|
|
516
|
+
return { ok: false, error: `no ${command} response found in log` };
|
|
517
|
+
}
|
|
518
|
+
|
|
458
519
|
// Wait for `{"type":"response","command":<command>}` after `since`, then parse
|
|
459
520
|
// it. Distinguishes: success, explicit failure (success:false → the error
|
|
460
521
|
// message, e.g. "No API key found for …"), subagent death, and timeout — this
|
|
@@ -465,7 +526,10 @@ async function rpcResponse(
|
|
|
465
526
|
command: string,
|
|
466
527
|
timeout = "30s",
|
|
467
528
|
signal?: AbortSignal,
|
|
468
|
-
): Promise<
|
|
529
|
+
): Promise<
|
|
530
|
+
| { ok: true; data?: Record<string, unknown>; offset: number }
|
|
531
|
+
| { ok: false; error: string }
|
|
532
|
+
> {
|
|
469
533
|
const e = await bs(
|
|
470
534
|
["expect", "-s", id, "--since", String(since), "--timeout", timeout, `"command":"${command}"`],
|
|
471
535
|
{ signal },
|
|
@@ -489,23 +553,26 @@ async function rpcResponse(
|
|
|
489
553
|
: e.stderr || `expect failed (code ${e.code})`,
|
|
490
554
|
};
|
|
491
555
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
|
|
556
|
+
try {
|
|
557
|
+
const file = logPath(id);
|
|
558
|
+
const size = fs.statSync(file).size;
|
|
559
|
+
const length = Math.max(0, size - since);
|
|
560
|
+
const bytes = Buffer.allocUnsafe(length);
|
|
561
|
+
const fd = fs.openSync(file, "r");
|
|
562
|
+
let read = 0;
|
|
496
563
|
try {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
if (
|
|
500
|
-
|
|
501
|
-
}
|
|
502
|
-
return { ok: true, data: ev.data as Record<string, unknown> | undefined };
|
|
564
|
+
while (read < length) {
|
|
565
|
+
const count = fs.readSync(fd, bytes, read, length - read, since + read);
|
|
566
|
+
if (count === 0) break;
|
|
567
|
+
read += count;
|
|
503
568
|
}
|
|
504
|
-
}
|
|
505
|
-
|
|
569
|
+
} finally {
|
|
570
|
+
fs.closeSync(fd);
|
|
506
571
|
}
|
|
572
|
+
return parseRpcResponseBytes(bytes.subarray(0, read), since, command);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
return { ok: false, error: `could not read ${command} response: ${String(error)}` };
|
|
507
575
|
}
|
|
508
|
-
return { ok: false, error: `no ${command} response found in log` };
|
|
509
576
|
}
|
|
510
577
|
|
|
511
578
|
// "5m" / "30s" / "2h" → milliseconds (null = no limit).
|
|
@@ -880,19 +947,27 @@ export function deliverProcessCompletionMessage(
|
|
|
880
947
|
// (A subagent-profile run does NOT carry the marker: ending a turn to "wait"
|
|
881
948
|
// for a subagent is a guidance violation, and treating it as completion keeps
|
|
882
949
|
// the parent from hanging forever.) `process` is the legacy pi-processes tool.
|
|
883
|
-
function
|
|
884
|
-
|
|
950
|
+
function isParkedMessages(
|
|
951
|
+
messages: { role?: string; toolName?: string; content?: unknown }[] | undefined,
|
|
885
952
|
): boolean {
|
|
886
|
-
if (!
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
return true;
|
|
953
|
+
if (!messages) return false;
|
|
954
|
+
// Models sometimes add a short assistant note after starting a process. Scan
|
|
955
|
+
// the run rather than requiring the marker-bearing tool result to be the last
|
|
956
|
+
// message, otherwise that harmless note turns a parked build into false task
|
|
957
|
+
// completion (and lets the self-reaper kill it).
|
|
958
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
959
|
+
const message = messages[i];
|
|
960
|
+
if (message?.role !== "toolResult") continue;
|
|
961
|
+
if (message.toolName === "process") return true; // legacy pi-processes
|
|
962
|
+
if (message.toolName !== "babysit_run") continue;
|
|
963
|
+
try {
|
|
964
|
+
const serialized = JSON.stringify(message.content ?? null);
|
|
965
|
+
if (serialized === "null" || serialized.includes(NOTIFY_MARKER)) return true;
|
|
966
|
+
} catch {
|
|
967
|
+
return true; // content unavailable — never false-kill a possible build wait
|
|
968
|
+
}
|
|
895
969
|
}
|
|
970
|
+
return false;
|
|
896
971
|
}
|
|
897
972
|
|
|
898
973
|
// ---------------------------------------------------------------------------
|
|
@@ -913,6 +988,7 @@ export interface Progress {
|
|
|
913
988
|
// RPC lifecycle bookkeeping (computed over the analyzed log slice):
|
|
914
989
|
agentStarts: number;
|
|
915
990
|
agentEnds: number;
|
|
991
|
+
agentSettled: number;
|
|
916
992
|
lastEndWasProcessWait: boolean;
|
|
917
993
|
running: boolean; // an agent run is in flight right now
|
|
918
994
|
waitingOnProcess: boolean; // idle, but a process resume is pending
|
|
@@ -946,82 +1022,158 @@ function summarizeToolCall(name: string, args: Record<string, unknown>): string
|
|
|
946
1022
|
}
|
|
947
1023
|
}
|
|
948
1024
|
|
|
949
|
-
|
|
950
|
-
|
|
1025
|
+
function emptyProgress(): Progress {
|
|
1026
|
+
return {
|
|
951
1027
|
turns: 0,
|
|
952
1028
|
toolCalls: [],
|
|
953
1029
|
finalText: "",
|
|
954
1030
|
agentStarts: 0,
|
|
955
1031
|
agentEnds: 0,
|
|
1032
|
+
agentSettled: 0,
|
|
956
1033
|
lastEndWasProcessWait: false,
|
|
957
1034
|
running: false,
|
|
958
1035
|
waitingOnProcess: false,
|
|
959
1036
|
done: false,
|
|
960
1037
|
};
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function updateProgressState(progress: Progress): Progress {
|
|
1041
|
+
progress.running = progress.agentStarts > progress.agentEnds;
|
|
1042
|
+
progress.waitingOnProcess =
|
|
1043
|
+
!progress.running && progress.agentEnds > 0 && progress.lastEndWasProcessWait;
|
|
1044
|
+
// `agent_end` is not final: Pi may still retry, compact-and-retry, or process
|
|
1045
|
+
// queued continuations. `agent_settled` is the authoritative completion event.
|
|
1046
|
+
progress.done =
|
|
1047
|
+
!progress.running &&
|
|
1048
|
+
progress.agentSettled > 0 &&
|
|
1049
|
+
!progress.lastEndWasProcessWait;
|
|
1050
|
+
return progress;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function parseEventLine(progress: Progress, raw: string): void {
|
|
1054
|
+
const line = raw.replace(/\r$/, "").trim();
|
|
1055
|
+
if (!line.startsWith("{")) return;
|
|
1056
|
+
let event: Record<string, unknown>;
|
|
1057
|
+
try {
|
|
1058
|
+
event = JSON.parse(line);
|
|
1059
|
+
} catch {
|
|
1060
|
+
return; // partial trailing line, etc.
|
|
1061
|
+
}
|
|
1062
|
+
switch (event.type) {
|
|
1063
|
+
case "turn_start":
|
|
1064
|
+
progress.turns++;
|
|
1065
|
+
break;
|
|
1066
|
+
case "tool_execution_start": {
|
|
1067
|
+
const name = String(event.toolName ?? "tool");
|
|
1068
|
+
progress.toolCalls.push({
|
|
1069
|
+
name,
|
|
1070
|
+
summary: summarizeToolCall(name, (event.args as Record<string, unknown>) ?? {}),
|
|
1071
|
+
});
|
|
1072
|
+
break;
|
|
969
1073
|
}
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
const
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
const msg = ev.message as
|
|
984
|
-
| { role?: string; content?: { type: string; text?: string }[]; usage?: { totalTokens?: number; cost?: { total?: number } } }
|
|
985
|
-
| undefined;
|
|
986
|
-
if (msg?.role === "assistant") {
|
|
987
|
-
const txt = (msg.content ?? [])
|
|
988
|
-
.filter((c) => c.type === "text" && c.text)
|
|
989
|
-
.map((c) => c.text)
|
|
990
|
-
.join("");
|
|
991
|
-
if (txt.trim()) p.finalText = txt; // keep the latest non-empty assistant text
|
|
992
|
-
if (msg.usage) {
|
|
993
|
-
p.tokens = msg.usage.totalTokens;
|
|
994
|
-
p.cost = msg.usage.cost?.total;
|
|
995
|
-
}
|
|
1074
|
+
case "message_end": {
|
|
1075
|
+
const message = event.message as
|
|
1076
|
+
| { role?: string; content?: { type: string; text?: string }[]; usage?: { totalTokens?: number; cost?: { total?: number } } }
|
|
1077
|
+
| undefined;
|
|
1078
|
+
if (message?.role === "assistant") {
|
|
1079
|
+
const text = (message.content ?? [])
|
|
1080
|
+
.filter((content) => content.type === "text" && content.text)
|
|
1081
|
+
.map((content) => content.text)
|
|
1082
|
+
.join("");
|
|
1083
|
+
if (text.trim()) progress.finalText = text;
|
|
1084
|
+
if (message.usage) {
|
|
1085
|
+
progress.tokens = message.usage.totalTokens;
|
|
1086
|
+
progress.cost = message.usage.cost?.total;
|
|
996
1087
|
}
|
|
997
|
-
break;
|
|
998
1088
|
}
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1089
|
+
break;
|
|
1090
|
+
}
|
|
1091
|
+
case "agent_start":
|
|
1092
|
+
progress.agentStarts++;
|
|
1093
|
+
break;
|
|
1094
|
+
case "agent_end": {
|
|
1095
|
+
progress.agentEnds++;
|
|
1096
|
+
progress.lastEndWasProcessWait = isParkedMessages(
|
|
1097
|
+
event.messages as
|
|
1005
1098
|
| { role?: string; toolName?: string; content?: unknown }[]
|
|
1006
|
-
| undefined
|
|
1007
|
-
|
|
1008
|
-
|
|
1099
|
+
| undefined,
|
|
1100
|
+
);
|
|
1101
|
+
break;
|
|
1102
|
+
}
|
|
1103
|
+
case "agent_settled":
|
|
1104
|
+
progress.agentSettled++;
|
|
1105
|
+
break;
|
|
1106
|
+
case "response":
|
|
1107
|
+
if (event.success === false) {
|
|
1108
|
+
progress.errorMsg = String(event.error ?? `rpc ${event.command ?? "command"} failed`);
|
|
1009
1109
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1110
|
+
break;
|
|
1111
|
+
case "error":
|
|
1112
|
+
progress.errorMsg = String(event.message ?? event.error ?? line);
|
|
1113
|
+
break;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
export function parseEvents(logText: string): Progress {
|
|
1118
|
+
const progress = emptyProgress();
|
|
1119
|
+
for (const line of logText.split("\n")) parseEventLine(progress, line);
|
|
1120
|
+
return updateProgressState(progress);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
interface TaskProgressCache {
|
|
1124
|
+
base: number;
|
|
1125
|
+
offset: number;
|
|
1126
|
+
pending: Buffer;
|
|
1127
|
+
progress: Progress;
|
|
1128
|
+
}
|
|
1129
|
+
const taskProgressCache = new Map<string, TaskProgressCache>();
|
|
1130
|
+
|
|
1131
|
+
/** Parse only bytes appended since the previous observation of this task. */
|
|
1132
|
+
function taskProgressOf(id: string): { progress: Progress; offset: number } {
|
|
1133
|
+
const base = readMeta(id)?.promptOffset ?? 0;
|
|
1134
|
+
const file = logPath(id);
|
|
1135
|
+
const size = fs.statSync(file).size;
|
|
1136
|
+
let cached = taskProgressCache.get(id);
|
|
1137
|
+
if (!cached || cached.base !== base || size < cached.offset) {
|
|
1138
|
+
cached = { base, offset: base, pending: Buffer.alloc(0), progress: emptyProgress() };
|
|
1139
|
+
taskProgressCache.set(id, cached);
|
|
1140
|
+
}
|
|
1141
|
+
if (size > cached.offset) {
|
|
1142
|
+
const added = Buffer.allocUnsafe(size - cached.offset);
|
|
1143
|
+
const fd = fs.openSync(file, "r");
|
|
1144
|
+
let read = 0;
|
|
1145
|
+
try {
|
|
1146
|
+
while (read < added.length) {
|
|
1147
|
+
const count = fs.readSync(fd, added, read, added.length - read, cached.offset + read);
|
|
1148
|
+
if (count === 0) break;
|
|
1149
|
+
read += count;
|
|
1150
|
+
}
|
|
1151
|
+
} finally {
|
|
1152
|
+
fs.closeSync(fd);
|
|
1019
1153
|
}
|
|
1154
|
+
cached.offset += read;
|
|
1155
|
+
const bytes = cached.pending.length
|
|
1156
|
+
? Buffer.concat([cached.pending, added.subarray(0, read)])
|
|
1157
|
+
: added.subarray(0, read);
|
|
1158
|
+
let start = 0;
|
|
1159
|
+
for (let index = 0; index < bytes.length; index++) {
|
|
1160
|
+
if (bytes[index] !== 0x0a) continue;
|
|
1161
|
+
parseEventLine(cached.progress, bytes.subarray(start, index).toString("utf8"));
|
|
1162
|
+
start = index + 1;
|
|
1163
|
+
}
|
|
1164
|
+
cached.pending = Buffer.from(bytes.subarray(start));
|
|
1165
|
+
}
|
|
1166
|
+
updateProgressState(cached.progress);
|
|
1167
|
+
if (cached.progress.agentEnds > 0) {
|
|
1168
|
+
const meta = readMeta(id);
|
|
1169
|
+
if (meta) cleanupMessageTempDirs(id, meta, cached.progress.agentEnds);
|
|
1020
1170
|
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1171
|
+
// If the writer was observed mid-record, let `babysit expect --since` start
|
|
1172
|
+
// before that partial line so a split agent_settled marker cannot be missed.
|
|
1173
|
+
return {
|
|
1174
|
+
progress: cached.progress,
|
|
1175
|
+
offset: cached.offset - cached.pending.length,
|
|
1176
|
+
};
|
|
1025
1177
|
}
|
|
1026
1178
|
|
|
1027
1179
|
// ---------------------------------------------------------------------------
|
|
@@ -1098,13 +1250,32 @@ function writePromptTempFile(agentName: string, prompt: string): string {
|
|
|
1098
1250
|
// Anything over this budget is written to a file instead, and only a short
|
|
1099
1251
|
// "read this file" instruction travels through the PTY.
|
|
1100
1252
|
const PTY_SAFE_MESSAGE_BYTES = 600;
|
|
1253
|
+
const messageNeedsReadTool = (text: string): boolean =>
|
|
1254
|
+
Buffer.byteLength(text, "utf-8") > PTY_SAFE_MESSAGE_BYTES;
|
|
1255
|
+
|
|
1256
|
+
interface DeliverableMessage {
|
|
1257
|
+
message: string;
|
|
1258
|
+
tempDir?: string;
|
|
1259
|
+
}
|
|
1101
1260
|
|
|
1102
|
-
function deliverableMessage(kind: "task" | "steering message", text: string):
|
|
1103
|
-
if (
|
|
1261
|
+
function deliverableMessage(kind: "task" | "steering message", text: string): DeliverableMessage {
|
|
1262
|
+
if (!messageNeedsReadTool(text)) return { message: text };
|
|
1104
1263
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-babysit-msg-"));
|
|
1105
1264
|
const file = path.join(dir, "message.md");
|
|
1106
1265
|
fs.writeFileSync(file, text, "utf-8");
|
|
1107
|
-
return
|
|
1266
|
+
return {
|
|
1267
|
+
message: `Your full ${kind} is in the file ${file} — read it with the Read tool FIRST, then carry it out exactly as written.`,
|
|
1268
|
+
tempDir: dir,
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function discardDelivery(delivery: DeliverableMessage): void {
|
|
1273
|
+
if (!delivery.tempDir) return;
|
|
1274
|
+
try {
|
|
1275
|
+
fs.rmSync(delivery.tempDir, { recursive: true, force: true });
|
|
1276
|
+
} catch {
|
|
1277
|
+
/* best-effort */
|
|
1278
|
+
}
|
|
1108
1279
|
}
|
|
1109
1280
|
|
|
1110
1281
|
interface SubagentOpts {
|
|
@@ -1132,12 +1303,29 @@ async function spawnSubagent(
|
|
|
1132
1303
|
const model = opts.model ?? opts.agent?.model;
|
|
1133
1304
|
if (model) piArgs.push("--model", model);
|
|
1134
1305
|
const tools = opts.tools ?? opts.agent?.tools;
|
|
1306
|
+
if (messageNeedsReadTool(opts.task) && tools?.length && !tools.includes("read")) {
|
|
1307
|
+
return {
|
|
1308
|
+
error:
|
|
1309
|
+
"This task exceeds the PTY-safe inline limit and must be delivered through a file, " +
|
|
1310
|
+
"but the subagent tool allowlist excludes `read`. Add `read` or shorten the task.",
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1135
1313
|
if (tools && tools.length > 0) piArgs.push("--tools", tools.join(","));
|
|
1136
1314
|
piArgs.push("--append-system-prompt", SUBAGENT_GUIDANCE);
|
|
1315
|
+
let promptTempFile: string | undefined;
|
|
1137
1316
|
if (opts.agent?.systemPrompt?.trim()) {
|
|
1138
|
-
|
|
1139
|
-
piArgs.push("--append-system-prompt",
|
|
1317
|
+
promptTempFile = writePromptTempFile(opts.agent.name, opts.agent.systemPrompt);
|
|
1318
|
+
piArgs.push("--append-system-prompt", promptTempFile);
|
|
1140
1319
|
}
|
|
1320
|
+
const cleanupPromptTemp = () => {
|
|
1321
|
+
if (!promptTempFile) return;
|
|
1322
|
+
try {
|
|
1323
|
+
fs.rmSync(path.dirname(promptTempFile), { recursive: true, force: true });
|
|
1324
|
+
} catch {
|
|
1325
|
+
/* best-effort */
|
|
1326
|
+
}
|
|
1327
|
+
promptTempFile = undefined;
|
|
1328
|
+
};
|
|
1141
1329
|
// Self-reaper: a finished subagent exits after a short idle grace instead of
|
|
1142
1330
|
// lingering until the absolute --timeout. Cancelled by a follow-up task, and
|
|
1143
1331
|
// it never reaps a turn parked on a process-exit notification. See self-reap.ts.
|
|
@@ -1178,12 +1366,14 @@ async function spawnSubagent(
|
|
|
1178
1366
|
},
|
|
1179
1367
|
});
|
|
1180
1368
|
if (r.code !== 0) {
|
|
1369
|
+
cleanupPromptTemp();
|
|
1181
1370
|
return { error: r.stderr || r.stdout || `babysit run failed (exit ${r.code}, no output) — check that \`${BABYSIT_BIN}\` works and ${ROOT} is writable` };
|
|
1182
1371
|
}
|
|
1183
1372
|
let id: string;
|
|
1184
1373
|
try {
|
|
1185
1374
|
id = JSON.parse(r.stdout).id;
|
|
1186
1375
|
} catch {
|
|
1376
|
+
cleanupPromptTemp();
|
|
1187
1377
|
return { error: `could not parse id from: ${r.stdout}` };
|
|
1188
1378
|
}
|
|
1189
1379
|
|
|
@@ -1200,12 +1390,25 @@ async function spawnSubagent(
|
|
|
1200
1390
|
});
|
|
1201
1391
|
|
|
1202
1392
|
// Wait for pi to boot (first JSON event in the log), then inject the task.
|
|
1203
|
-
|
|
1393
|
+
// Pi has loaded --append-system-prompt before emitting that event, so the
|
|
1394
|
+
// anonymous prompt file can be removed immediately instead of leaking.
|
|
1395
|
+
const boot = await bs(["expect", "-s", id, "--timeout", "30s", '\\{"type"']);
|
|
1396
|
+
cleanupPromptTemp();
|
|
1397
|
+
if (boot.code !== 0) {
|
|
1398
|
+
await bs(["kill", "-s", id]);
|
|
1399
|
+
return {
|
|
1400
|
+
error: boot.code === 124
|
|
1401
|
+
? `subagent ${id} did not emit an RPC startup event within 30s`
|
|
1402
|
+
: boot.stderr || `subagent ${id} startup probe failed (code ${boot.code})`,
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
const delivery = deliverableMessage("task", opts.task);
|
|
1204
1406
|
const sent = await sendRpc(id, {
|
|
1205
1407
|
type: "prompt",
|
|
1206
|
-
message: `Task: ${
|
|
1408
|
+
message: `Task: ${delivery.message}`,
|
|
1207
1409
|
});
|
|
1208
1410
|
if ("error" in sent) {
|
|
1411
|
+
discardDelivery(delivery);
|
|
1209
1412
|
await bs(["kill", "-s", id]);
|
|
1210
1413
|
return { error: `could not send task to subagent ${id}: ${sent.error}` };
|
|
1211
1414
|
}
|
|
@@ -1213,6 +1416,7 @@ async function spawnSubagent(
|
|
|
1213
1416
|
// and similar config errors surface — fail the spawn loudly).
|
|
1214
1417
|
const resp = await rpcResponse(id, sent.offset, "prompt", "60s");
|
|
1215
1418
|
if (!resp.ok) {
|
|
1419
|
+
discardDelivery(delivery);
|
|
1216
1420
|
await bs(["kill", "-s", id]);
|
|
1217
1421
|
return { error: `subagent ${id} rejected the task: ${resp.error}` };
|
|
1218
1422
|
}
|
|
@@ -1225,6 +1429,7 @@ async function spawnSubagent(
|
|
|
1225
1429
|
if (st.ok && st.data) {
|
|
1226
1430
|
const m = (st.data as { model?: { id?: string } | null }).model;
|
|
1227
1431
|
if (m === null) {
|
|
1432
|
+
discardDelivery(delivery);
|
|
1228
1433
|
await bs(["kill", "-s", id]);
|
|
1229
1434
|
return {
|
|
1230
1435
|
error: `subagent ${id} has no usable model${opts.model ? ` (requested "${opts.model}")` : ""} — check the model name with \`pi --list-models\`.`,
|
|
@@ -1237,8 +1442,12 @@ async function spawnSubagent(
|
|
|
1237
1442
|
writeMeta(id, {
|
|
1238
1443
|
kind: "subagent",
|
|
1239
1444
|
task: opts.task,
|
|
1240
|
-
promptOffset:
|
|
1445
|
+
promptOffset: resp.offset,
|
|
1241
1446
|
model: resolvedModel,
|
|
1447
|
+
tools,
|
|
1448
|
+
messageTempDirs: delivery.tempDir
|
|
1449
|
+
? [{ dir: delivery.tempDir, afterAgentEnd: 1 }]
|
|
1450
|
+
: undefined,
|
|
1242
1451
|
depth: opts.depth,
|
|
1243
1452
|
maxDepth: opts.maxDepth,
|
|
1244
1453
|
startedAt: Date.now(),
|
|
@@ -1282,43 +1491,31 @@ function sanitizeTailLine(s: string): string {
|
|
|
1282
1491
|
// Trailing lines to show for a running session (sanitized, unprefixed).
|
|
1283
1492
|
// process → raw log tail; subagent → derived activity (recent tool calls /
|
|
1284
1493
|
// partial answer), since its log is RPC JSONL, not human-readable.
|
|
1285
|
-
async function widgetTail(
|
|
1494
|
+
async function widgetTail(
|
|
1495
|
+
id: string,
|
|
1496
|
+
isSub: boolean,
|
|
1497
|
+
subagentProgress?: Progress,
|
|
1498
|
+
): Promise<string[]> {
|
|
1286
1499
|
let raw: string[];
|
|
1287
1500
|
if (!isSub) {
|
|
1288
1501
|
const out = (await bs(["log", "-s", id, "--tail", String(WIDGET_TAIL_LINES)])).stdout;
|
|
1289
1502
|
raw = out.split("\n");
|
|
1290
1503
|
} else {
|
|
1291
|
-
const
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
raw = prog.finalText.trim().split("\n");
|
|
1297
|
-
} else if (prog.toolCalls.length > 0) {
|
|
1298
|
-
raw = prog.toolCalls.map((t) => t.summary);
|
|
1504
|
+
const progress = subagentProgress ?? taskProgressOf(id).progress;
|
|
1505
|
+
if (progress.finalText.trim()) {
|
|
1506
|
+
raw = progress.finalText.trim().split("\n");
|
|
1507
|
+
} else if (progress.toolCalls.length > 0) {
|
|
1508
|
+
raw = progress.toolCalls.map((tool) => tool.summary);
|
|
1299
1509
|
} else {
|
|
1300
|
-
raw =
|
|
1510
|
+
raw = progress.errorMsg ? [`⚠ ${progress.errorMsg}`] : [];
|
|
1301
1511
|
}
|
|
1302
1512
|
}
|
|
1303
1513
|
return raw
|
|
1304
1514
|
.map(sanitizeTailLine)
|
|
1305
|
-
.filter((
|
|
1515
|
+
.filter((line) => line.trim().length > 0)
|
|
1306
1516
|
.slice(-WIDGET_TAIL_LINES);
|
|
1307
1517
|
}
|
|
1308
1518
|
|
|
1309
|
-
// Classify a running subagent as busy or idle by analyzing the current task's
|
|
1310
|
-
// log slice (same logic babysit_check uses).
|
|
1311
|
-
async function isTaskDone(id: string): Promise<boolean> {
|
|
1312
|
-
try {
|
|
1313
|
-
const meta = readMeta(id);
|
|
1314
|
-
const logArgs = ["log", "-s", id];
|
|
1315
|
-
if (meta?.promptOffset) logArgs.push("--since", String(meta.promptOffset));
|
|
1316
|
-
return parseEvents((await bs(logArgs)).stdout).done;
|
|
1317
|
-
} catch {
|
|
1318
|
-
return false; // when unsure, treat as busy — never hide a live worker
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
1519
|
// ---------------------------------------------------------------------------
|
|
1323
1520
|
// human take-over hint (no tmux dependency)
|
|
1324
1521
|
// ---------------------------------------------------------------------------
|
|
@@ -1343,32 +1540,25 @@ interface WaitOutcome {
|
|
|
1343
1540
|
progress?: Progress;
|
|
1344
1541
|
}
|
|
1345
1542
|
|
|
1346
|
-
// Wait for ONE subagent's current task. Completion =
|
|
1347
|
-
//
|
|
1348
|
-
//
|
|
1349
|
-
//
|
|
1350
|
-
// block on the next agent_end via `babysit expect` (race-free byte offsets).
|
|
1543
|
+
// Wait for ONE subagent's current task. Completion = agent_settled without a
|
|
1544
|
+
// parked babysit_run/process result (a parked run only means "waiting for
|
|
1545
|
+
// process exit; pi will resume itself"). Parse appended bytes incrementally,
|
|
1546
|
+
// then block on the next agent_settled via race-free byte offsets.
|
|
1351
1547
|
async function waitForTask(
|
|
1352
1548
|
id: string,
|
|
1353
1549
|
limitMs: number | null,
|
|
1354
1550
|
signal?: AbortSignal,
|
|
1355
1551
|
): Promise<WaitOutcome> {
|
|
1356
|
-
const meta = readMeta(id);
|
|
1357
|
-
const base = meta?.promptOffset ?? 0;
|
|
1358
1552
|
const t0 = Date.now();
|
|
1359
1553
|
|
|
1360
1554
|
for (;;) {
|
|
1361
|
-
|
|
1362
|
-
let text = "";
|
|
1363
|
-
let cur = base;
|
|
1555
|
+
let observed: { progress: Progress; offset: number };
|
|
1364
1556
|
try {
|
|
1365
|
-
|
|
1366
|
-
text = j.text ?? "";
|
|
1367
|
-
cur = j.offset ?? base;
|
|
1557
|
+
observed = taskProgressOf(id);
|
|
1368
1558
|
} catch {
|
|
1369
|
-
|
|
1559
|
+
observed = { progress: emptyProgress(), offset: readMeta(id)?.promptOffset ?? 0 };
|
|
1370
1560
|
}
|
|
1371
|
-
const prog =
|
|
1561
|
+
const { progress: prog, offset: cur } = observed;
|
|
1372
1562
|
const st = await statusOf(id);
|
|
1373
1563
|
|
|
1374
1564
|
const stats =
|
|
@@ -1425,7 +1615,8 @@ async function waitForTask(
|
|
|
1425
1615
|
progress: prog,
|
|
1426
1616
|
});
|
|
1427
1617
|
|
|
1428
|
-
// Still working — block
|
|
1618
|
+
// Still working — block until Pi declares the run fully settled. Unlike
|
|
1619
|
+
// agent_end, this cannot fire before an automatic retry/compaction retry.
|
|
1429
1620
|
let expectTimeout = "0"; // indefinite
|
|
1430
1621
|
if (limitMs != null) {
|
|
1431
1622
|
const remaining = limitMs - (Date.now() - t0);
|
|
@@ -1433,7 +1624,7 @@ async function waitForTask(
|
|
|
1433
1624
|
expectTimeout = `${Math.ceil(remaining / 1000)}s`;
|
|
1434
1625
|
}
|
|
1435
1626
|
const e = await bs(
|
|
1436
|
-
["expect", "-s", id, "--since", String(cur), "--timeout", expectTimeout, '"type":"
|
|
1627
|
+
["expect", "-s", id, "--since", String(cur), "--timeout", expectTimeout, '"type":"agent_settled"'],
|
|
1437
1628
|
{ signal },
|
|
1438
1629
|
);
|
|
1439
1630
|
if (signal?.aborted || e.code === 130) {
|
|
@@ -1658,14 +1849,15 @@ export function activeToolsWithoutDirectBash(activeTools: string[], allowDirectB
|
|
|
1658
1849
|
|
|
1659
1850
|
export default function (pi: ExtensionAPI) {
|
|
1660
1851
|
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
1852
|
+
let pollNeeded = true;
|
|
1661
1853
|
const declaredToolErrors = new Set<string>();
|
|
1662
1854
|
|
|
1663
1855
|
// Pi only persists custom-tool failures when they are thrown or patched by a
|
|
1664
1856
|
// tool_result hook; an `isError` property returned from execute() is ignored.
|
|
1665
1857
|
// Keep the structured result (status, log path, diagnostics) while promoting
|
|
1666
1858
|
// its declared error bit at the supported hook boundary.
|
|
1667
|
-
const registerTool = <TParams extends TSchema
|
|
1668
|
-
tool: ToolDefinition<TParams,
|
|
1859
|
+
const registerTool = <TParams extends TSchema>(
|
|
1860
|
+
tool: ToolDefinition<TParams, unknown, unknown>,
|
|
1669
1861
|
): void => {
|
|
1670
1862
|
const execute = tool.execute;
|
|
1671
1863
|
pi.registerTool({
|
|
@@ -1690,12 +1882,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1690
1882
|
// that ended its turn after babysit_run without spending one turn per exit.
|
|
1691
1883
|
// Kills via babysit_kill and exits already reported by babysit_wait are
|
|
1692
1884
|
// suppressed via meta.notified.
|
|
1693
|
-
async function notifyEndedProcesses(
|
|
1885
|
+
async function notifyEndedProcesses(
|
|
1886
|
+
ctx: ExtensionContext,
|
|
1887
|
+
snapshot?: BsSession[],
|
|
1888
|
+
): Promise<void> {
|
|
1694
1889
|
// Never steer a completion into an active agent turn. In particular, this
|
|
1695
1890
|
// lets an immediately-following babysit_wait reserve the completion first,
|
|
1696
1891
|
// instead of racing the poller and receiving both wait + auto notification.
|
|
1697
1892
|
if (shouldDeferCompletionNotification(ctx.isIdle())) return;
|
|
1698
|
-
const
|
|
1893
|
+
const sessions = snapshot ?? (await listSessions()).sessions;
|
|
1699
1894
|
const ready: Array<{ session: BsSession; meta: Meta }> = [];
|
|
1700
1895
|
for (const session of sessions) {
|
|
1701
1896
|
if (session.state === "running") continue;
|
|
@@ -1769,30 +1964,45 @@ export default function (pi: ExtensionAPI) {
|
|
|
1769
1964
|
);
|
|
1770
1965
|
}
|
|
1771
1966
|
|
|
1772
|
-
const refreshWidget = async (ctx: ExtensionContext) => {
|
|
1967
|
+
const refreshWidget = async (ctx: ExtensionContext, snapshot?: BsSession[]) => {
|
|
1773
1968
|
if (!ctx.hasUI) return;
|
|
1774
|
-
const active = (await listSessions()).sessions.filter(
|
|
1775
|
-
|
|
1969
|
+
const active = (snapshot ?? (await listSessions()).sessions).filter(
|
|
1970
|
+
(session) => session.state === "running",
|
|
1971
|
+
);
|
|
1972
|
+
const subs = active.filter((session) => kindOf(session.id) === "subagent");
|
|
1776
1973
|
const procs = active.length - subs.length;
|
|
1777
|
-
const
|
|
1778
|
-
const
|
|
1974
|
+
const progressById = new Map<string, Progress>();
|
|
1975
|
+
for (const subagent of subs) {
|
|
1976
|
+
try {
|
|
1977
|
+
progressById.set(subagent.id, taskProgressOf(subagent.id).progress);
|
|
1978
|
+
} catch {
|
|
1979
|
+
progressById.set(subagent.id, emptyProgress());
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
const idle = subs.filter((session) => progressById.get(session.id)?.done).length;
|
|
1779
1983
|
const lines = renderWidgetLines(procs, subs.length - idle, idle);
|
|
1780
|
-
// Per-session elapsed time (live: refreshed every poll tick).
|
|
1781
|
-
const doneById = new Map(subs.map((s, i) => [s.id, doneFlags[i]]));
|
|
1782
1984
|
const tails = await Promise.all(
|
|
1783
|
-
active.map((
|
|
1985
|
+
active.map((session) => {
|
|
1986
|
+
const isSubagent = kindOf(session.id) === "subagent";
|
|
1987
|
+
return widgetTail(
|
|
1988
|
+
session.id,
|
|
1989
|
+
isSubagent,
|
|
1990
|
+
isSubagent ? progressById.get(session.id) : undefined,
|
|
1991
|
+
);
|
|
1992
|
+
}),
|
|
1784
1993
|
);
|
|
1785
|
-
active.forEach((
|
|
1786
|
-
const
|
|
1787
|
-
const tag =
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1994
|
+
active.forEach((session, index) => {
|
|
1995
|
+
const isSubagent = kindOf(session.id) === "subagent";
|
|
1996
|
+
const tag = isSubagent
|
|
1997
|
+
? progressById.get(session.id)?.done ? "sub idle" : "sub"
|
|
1998
|
+
: "proc";
|
|
1999
|
+
const elapsed = elapsedOf(session.id);
|
|
2000
|
+
const header = ` ⏳ ${session.id} [${tag}]${elapsed ? ` ${elapsed}` : ""}`;
|
|
2001
|
+
if (tails[index].length === 1) {
|
|
2002
|
+
lines.push(`${header} │ ${tails[index][0]}`);
|
|
1793
2003
|
} else {
|
|
1794
2004
|
lines.push(header);
|
|
1795
|
-
for (const
|
|
2005
|
+
for (const tail of tails[index]) lines.push(` │ ${tail}`);
|
|
1796
2006
|
}
|
|
1797
2007
|
});
|
|
1798
2008
|
ctx.ui.setWidget("pi-babysit", lines, { placement: "belowEditor" });
|
|
@@ -1887,20 +2097,36 @@ export default function (pi: ExtensionAPI) {
|
|
|
1887
2097
|
} catch {
|
|
1888
2098
|
ROOT = ROOT_BASE;
|
|
1889
2099
|
}
|
|
2100
|
+
taskProgressCache.clear();
|
|
2101
|
+
pollNeeded = true;
|
|
1890
2102
|
// Warn early if the binary is missing so the user isn't surprised only when
|
|
1891
2103
|
// a tool later fails. Tools/commands still enforce it via requireBabysit.
|
|
1892
2104
|
if (ctx.hasUI && !(await babysitAvailable())) {
|
|
1893
|
-
ctx.ui.notify(babysitPreflightError ?? INSTALL_HINT, "
|
|
2105
|
+
ctx.ui.notify(babysitPreflightError ?? INSTALL_HINT, "warning");
|
|
1894
2106
|
}
|
|
1895
2107
|
if (pollTimer) clearInterval(pollTimer);
|
|
1896
2108
|
pollTimer = setInterval(() => {
|
|
1897
|
-
//
|
|
1898
|
-
//
|
|
1899
|
-
|
|
2109
|
+
// Once a namespace has no live workers or pending notifications, avoid
|
|
2110
|
+
// spawning `babysit list` forever while the UI is idle. A successful
|
|
2111
|
+
// babysit_run re-arms polling below.
|
|
2112
|
+
if (polling || !pollNeeded) return;
|
|
1900
2113
|
polling = true;
|
|
1901
|
-
|
|
2114
|
+
void (async () => {
|
|
2115
|
+
const listed = await listSessions();
|
|
2116
|
+
if (listed.error) {
|
|
2117
|
+
pollNeeded = shouldKeepPollingAfterList(listed, readMeta);
|
|
2118
|
+
throw new Error(listed.error);
|
|
2119
|
+
}
|
|
2120
|
+
const snapshot = listed.sessions;
|
|
2121
|
+
await Promise.all([
|
|
2122
|
+
notifyEndedProcesses(ctx, snapshot),
|
|
2123
|
+
refreshWidget(ctx, snapshot),
|
|
2124
|
+
]);
|
|
2125
|
+
pollNeeded = shouldKeepPolling(snapshot, readMeta);
|
|
2126
|
+
})()
|
|
1902
2127
|
.catch(() => {
|
|
1903
|
-
|
|
2128
|
+
// Keep polling armed after a transient CLI/filesystem error.
|
|
2129
|
+
pollNeeded = true;
|
|
1904
2130
|
})
|
|
1905
2131
|
.finally(() => {
|
|
1906
2132
|
polling = false;
|
|
@@ -2091,6 +2317,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2091
2317
|
details: {},
|
|
2092
2318
|
};
|
|
2093
2319
|
}
|
|
2320
|
+
pollNeeded = true;
|
|
2094
2321
|
await refreshWidget(ctx);
|
|
2095
2322
|
|
|
2096
2323
|
// One-shot / non-interactive mode (e.g. `pi -p`): there is no live
|
|
@@ -2235,6 +2462,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2235
2462
|
};
|
|
2236
2463
|
}
|
|
2237
2464
|
|
|
2465
|
+
pollNeeded = true;
|
|
2238
2466
|
await refreshWidget(ctx);
|
|
2239
2467
|
return {
|
|
2240
2468
|
content: [
|
|
@@ -2439,10 +2667,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
2439
2667
|
};
|
|
2440
2668
|
}
|
|
2441
2669
|
|
|
2442
|
-
// --- subagent: analyze only the current task
|
|
2443
|
-
const
|
|
2444
|
-
if (meta?.promptOffset) logArgs.push("--since", String(meta.promptOffset));
|
|
2445
|
-
const prog = parseEvents((await bs(logArgs)).stdout);
|
|
2670
|
+
// --- subagent: analyze only bytes appended for the current task ---
|
|
2671
|
+
const prog = taskProgressOf(params.id).progress;
|
|
2446
2672
|
const nTools = Math.min(Math.max(1, params.tools ?? 8), 50);
|
|
2447
2673
|
const recent = prog.toolCalls.slice(-nTools);
|
|
2448
2674
|
|
|
@@ -2489,7 +2715,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2489
2715
|
}
|
|
2490
2716
|
|
|
2491
2717
|
return {
|
|
2492
|
-
content: [{ type: "text", text: parts.join("\n") }],
|
|
2718
|
+
content: [{ type: "text", text: clip(parts.join("\n")) }],
|
|
2493
2719
|
details: { status: st, progress: prog, kind: "subagent" },
|
|
2494
2720
|
};
|
|
2495
2721
|
},
|
|
@@ -2591,6 +2817,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
2591
2817
|
details: {},
|
|
2592
2818
|
};
|
|
2593
2819
|
}
|
|
2820
|
+
if (messageNeedsReadTool(params.text) && meta.tools?.length && !meta.tools.includes("read")) {
|
|
2821
|
+
return {
|
|
2822
|
+
content: [
|
|
2823
|
+
{
|
|
2824
|
+
type: "text",
|
|
2825
|
+
text: "This message is too large for PTY-safe inline delivery, but the subagent cannot read the required temporary file. Add `read` to its tool allowlist or shorten the message.",
|
|
2826
|
+
},
|
|
2827
|
+
],
|
|
2828
|
+
isError: true,
|
|
2829
|
+
details: {},
|
|
2830
|
+
};
|
|
2831
|
+
}
|
|
2594
2832
|
let mode = params.mode ?? "auto";
|
|
2595
2833
|
if (mode === "auto") {
|
|
2596
2834
|
// isStreaming tells us whether an agent run is in flight right now.
|
|
@@ -2602,12 +2840,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
2602
2840
|
}
|
|
2603
2841
|
mode = streaming ? "steer" : "task";
|
|
2604
2842
|
}
|
|
2843
|
+
const deliveryCleanupAfter =
|
|
2844
|
+
mode === "steer"
|
|
2845
|
+
? (() => {
|
|
2846
|
+
try {
|
|
2847
|
+
return taskProgressOf(params.id).progress.agentEnds + 1;
|
|
2848
|
+
} catch {
|
|
2849
|
+
return 1;
|
|
2850
|
+
}
|
|
2851
|
+
})()
|
|
2852
|
+
: 1;
|
|
2853
|
+
const delivery = deliverableMessage(
|
|
2854
|
+
mode === "steer" ? "steering message" : "task",
|
|
2855
|
+
params.text,
|
|
2856
|
+
);
|
|
2605
2857
|
const cmd =
|
|
2606
2858
|
mode === "steer"
|
|
2607
|
-
? { type: "steer", message:
|
|
2608
|
-
: { type: "prompt", message:
|
|
2859
|
+
? { type: "steer", message: delivery.message }
|
|
2860
|
+
: { type: "prompt", message: delivery.message };
|
|
2609
2861
|
const sent = await sendRpc(params.id, cmd);
|
|
2610
2862
|
if ("error" in sent) {
|
|
2863
|
+
discardDelivery(delivery);
|
|
2611
2864
|
return {
|
|
2612
2865
|
content: [{ type: "text", text: sent.error }],
|
|
2613
2866
|
isError: true,
|
|
@@ -2616,6 +2869,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2616
2869
|
}
|
|
2617
2870
|
const resp = await rpcResponse(params.id, sent.offset, cmd.type, "15s");
|
|
2618
2871
|
if (!resp.ok) {
|
|
2872
|
+
discardDelivery(delivery);
|
|
2619
2873
|
return {
|
|
2620
2874
|
content: [{ type: "text", text: `${cmd.type} was not accepted: ${resp.error}` }],
|
|
2621
2875
|
isError: true,
|
|
@@ -2627,11 +2881,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
2627
2881
|
writeMeta(params.id, {
|
|
2628
2882
|
kind: "subagent",
|
|
2629
2883
|
task: params.text,
|
|
2630
|
-
|
|
2884
|
+
// Start after the prompt-acceptance response. This excludes a
|
|
2885
|
+
// preceding run that settled between `send` and RPC acceptance.
|
|
2886
|
+
promptOffset: resp.offset,
|
|
2631
2887
|
model: meta?.model,
|
|
2888
|
+
tools: meta?.tools,
|
|
2889
|
+
messageTempDirs: delivery.tempDir
|
|
2890
|
+
? [{ dir: delivery.tempDir, afterAgentEnd: deliveryCleanupAfter }]
|
|
2891
|
+
: undefined,
|
|
2632
2892
|
depth: meta?.depth,
|
|
2633
2893
|
maxDepth: meta?.maxDepth,
|
|
2634
2894
|
});
|
|
2895
|
+
} else if (delivery.tempDir && meta) {
|
|
2896
|
+
writeMeta(params.id, {
|
|
2897
|
+
...meta,
|
|
2898
|
+
messageTempDirs: [
|
|
2899
|
+
...(meta.messageTempDirs ?? []),
|
|
2900
|
+
{ dir: delivery.tempDir, afterAgentEnd: deliveryCleanupAfter },
|
|
2901
|
+
],
|
|
2902
|
+
});
|
|
2635
2903
|
}
|
|
2636
2904
|
return {
|
|
2637
2905
|
content: [
|
|
@@ -2694,6 +2962,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
2694
2962
|
};
|
|
2695
2963
|
}
|
|
2696
2964
|
const limitMs = parseDurMs(params.timeout);
|
|
2965
|
+
if (params.timeout !== undefined && limitMs === null) {
|
|
2966
|
+
throw new Error(
|
|
2967
|
+
`Invalid timeout ${JSON.stringify(params.timeout)}; use an integer with ms, s, m, or h (for example "90s" or "5m").`,
|
|
2968
|
+
);
|
|
2969
|
+
}
|
|
2697
2970
|
|
|
2698
2971
|
if (ids.length === 1) {
|
|
2699
2972
|
const r = await waitFor(ids[0], limitMs, signal, params.expect);
|
|
@@ -2719,7 +2992,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2719
2992
|
content: [
|
|
2720
2993
|
{
|
|
2721
2994
|
type: "text",
|
|
2722
|
-
text:
|
|
2995
|
+
text: clip(
|
|
2996
|
+
results.map((result) => `── ${result.id} [${result.kind}] ──\n${result.text}`).join("\n\n"),
|
|
2997
|
+
ANSWER_MAX_BYTES,
|
|
2998
|
+
),
|
|
2723
2999
|
},
|
|
2724
3000
|
],
|
|
2725
3001
|
isError: !ok,
|
|
@@ -2743,10 +3019,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
2743
3019
|
content: [
|
|
2744
3020
|
{
|
|
2745
3021
|
type: "text",
|
|
2746
|
-
text:
|
|
3022
|
+
text: clip(
|
|
2747
3023
|
`First to finish: ${first.id} [${first.kind}]` +
|
|
2748
|
-
|
|
2749
|
-
|
|
3024
|
+
(others.length ? ` — still waiting-able: ${others.join(", ")}` : "") +
|
|
3025
|
+
`\n\n${first.text}`,
|
|
3026
|
+
ANSWER_MAX_BYTES,
|
|
3027
|
+
),
|
|
2750
3028
|
},
|
|
2751
3029
|
],
|
|
2752
3030
|
isError: !first.ok,
|
|
@@ -2860,7 +3138,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2860
3138
|
// Inline snapshot (running) or summary (finished) — no tmux window.
|
|
2861
3139
|
if (kind === "subagent") {
|
|
2862
3140
|
// Parse the RPC event stream and show the final answer, not raw JSONL.
|
|
2863
|
-
const prog =
|
|
3141
|
+
const prog = taskProgressOf(picked.id).progress;
|
|
2864
3142
|
const stats =
|
|
2865
3143
|
`turns=${prog.turns} tools=${prog.toolCalls.length}` +
|
|
2866
3144
|
(prog.tokens != null ? ` ctx=${prog.tokens}` : "") +
|
package/package.json
CHANGED
package/self-reap.ts
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* keep talking to never dies; only a genuinely-abandoned one does.
|
|
14
14
|
* 2. Process parks — a turn that ends only to await a background-process
|
|
15
15
|
* exit notification (babysit_run inside the subagent, or the legacy
|
|
16
|
-
* `process` tool) also
|
|
17
|
-
* detect
|
|
16
|
+
* `process` tool) also settles the agent, but pi resumes on its own. We
|
|
17
|
+
* detect a parked toolResult anywhere in that run (same rule the parent
|
|
18
18
|
* uses) and DON'T schedule a reap, so a subagent waiting on a long
|
|
19
19
|
* build/test is never false-killed.
|
|
20
20
|
*
|
|
@@ -36,22 +36,25 @@ function parseDurMs(s: string | undefined): number | null {
|
|
|
36
36
|
return n * (u === "ms" ? 1 : u === "s" ? 1000 : u === "m" ? 60_000 : 3_600_000);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
// Same parked-turn rule as the parent
|
|
40
|
-
//
|
|
41
|
-
// as the LAST message means "parked awaiting the exit notification".
|
|
39
|
+
// Same parked-turn rule as the parent: scan the run because models sometimes
|
|
40
|
+
// add an assistant note after the marker-bearing tool result.
|
|
42
41
|
function isParked(
|
|
43
|
-
|
|
42
|
+
messages: { role?: string; toolName?: string; content?: unknown }[] | undefined,
|
|
44
43
|
): boolean {
|
|
45
|
-
if (!
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
44
|
+
if (!messages) return false;
|
|
45
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
46
|
+
const message = messages[index];
|
|
47
|
+
if (message?.role !== "toolResult") continue;
|
|
48
|
+
if (message.toolName === "process") return true; // legacy pi-processes
|
|
49
|
+
if (message.toolName !== "babysit_run") continue;
|
|
50
|
+
try {
|
|
51
|
+
const serialized = JSON.stringify(message.content ?? null);
|
|
52
|
+
if (serialized === "null" || serialized.includes(NOTIFY_MARKER)) return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
54
56
|
}
|
|
57
|
+
return false;
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
export default function (pi: ExtensionAPI) {
|
|
@@ -61,6 +64,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
61
64
|
if (graceMs == null) return; // reaping disabled
|
|
62
65
|
|
|
63
66
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
67
|
+
let lastEndWasParked = false;
|
|
64
68
|
const cancel = () => {
|
|
65
69
|
if (timer) {
|
|
66
70
|
clearTimeout(timer);
|
|
@@ -71,22 +75,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
71
75
|
// A new task/turn is starting — the subagent is wanted again. Stand down.
|
|
72
76
|
pi.on("before_agent_start", () => {
|
|
73
77
|
cancel();
|
|
78
|
+
lastEndWasParked = false;
|
|
74
79
|
});
|
|
75
80
|
|
|
76
81
|
pi.on("agent_end", (event) => {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const msgs = (
|
|
82
|
+
const messages = (
|
|
80
83
|
event as { messages?: { role?: string; toolName?: string; content?: unknown }[] }
|
|
81
84
|
).messages;
|
|
82
|
-
|
|
85
|
+
lastEndWasParked = isParked(messages);
|
|
86
|
+
});
|
|
83
87
|
|
|
88
|
+
// agent_end can precede automatic retries, compaction retries, and queued
|
|
89
|
+
// continuations. Start the grace timer only once Pi is truly settled.
|
|
90
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
91
|
+
cancel();
|
|
92
|
+
if (lastEndWasParked) return;
|
|
84
93
|
timer = setTimeout(() => {
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
process.exit(0);
|
|
94
|
+
// Graceful shutdown emits session_shutdown for every loaded extension.
|
|
95
|
+
ctx.shutdown();
|
|
88
96
|
}, graceMs);
|
|
89
|
-
// Don't let this timer keep the event loop alive on its own.
|
|
90
97
|
timer.unref?.();
|
|
91
98
|
});
|
|
92
99
|
}
|