@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 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 }` | `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 |
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":"agent_end"'`.
134
- An `agent_end` whose last message is a **parked** toolResult — a
135
- `babysit_run { command }` result carrying the `[notify-on-exit]` marker (or
136
- the legacy `process` tool) — only means "turn parked awaiting a process-exit
137
- notification; pi resumes on its own", so the wait continues. Any other
138
- `agent_end` is real completion. Per-task byte offsets scope check/wait to the
139
- CURRENT task, which is what makes follow-up tasks work.
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
- const { frontmatter, body } =
60
- parseFrontmatter<Record<string, string>>(content);
61
- if (!frontmatter.name || !frontmatter.description) continue;
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
- ?.split(",")
65
- .map((t) => t.trim())
66
- .filter(Boolean);
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: tools && tools.length > 0 ? tools : undefined,
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 (`agent_end`), NOT process exit; the session
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 isParkedToolResult / self-reap.ts).
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
- // `status --json` shape: { session, status: { state, exit_code, ... } }.
306
- // `note` lives only in `list --json`, so fold it in from there.
307
- const r = await bs(["status", "-s", id, "--json"]);
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
- const parsed = JSON.parse(r.stdout);
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
- kind: "process" | "subagent";
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<{ ok: true; data?: Record<string, unknown> } | { ok: false; error: string }> {
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
- const lg = await bs(["log", "-s", id, "--since", String(since)]);
493
- for (const raw of lg.stdout.split("\n")) {
494
- const line = raw.replace(/\r$/, "").trim();
495
- if (!line.startsWith("{")) continue;
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
- const ev = JSON.parse(line);
498
- if (ev.type === "response" && ev.command === command) {
499
- if (ev.success === false) {
500
- return { ok: false, error: clip(String(ev.error ?? `${command} failed`)) };
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
- } catch {
505
- /* partial line */
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 isParkedToolResult(
884
- last: { role?: string; toolName?: string; content?: unknown } | undefined,
950
+ function isParkedMessages(
951
+ messages: { role?: string; toolName?: string; content?: unknown }[] | undefined,
885
952
  ): boolean {
886
- if (!last || last.role !== "toolResult") return false;
887
- if (last.toolName === "process") return true; // legacy
888
- if (last.toolName !== "babysit_run") return false;
889
- try {
890
- const s = JSON.stringify(last.content ?? null);
891
- if (s === "null") return true; // content unavailable — err on "parked" (never false-kill a build wait)
892
- return s.includes(NOTIFY_MARKER);
893
- } catch {
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
- export function parseEvents(logText: string): Progress {
950
- const p: Progress = {
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
- for (const raw of logText.split("\n")) {
962
- const line = raw.replace(/\r$/, "").trim();
963
- if (!line.startsWith("{")) continue;
964
- let ev: Record<string, unknown>;
965
- try {
966
- ev = JSON.parse(line);
967
- } catch {
968
- continue; // partial trailing line, etc.
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
- switch (ev.type) {
971
- case "turn_start":
972
- p.turns++;
973
- break;
974
- case "tool_execution_start": {
975
- const name = String(ev.toolName ?? "tool");
976
- p.toolCalls.push({
977
- name,
978
- summary: summarizeToolCall(name, (ev.args as Record<string, unknown>) ?? {}),
979
- });
980
- break;
981
- }
982
- case "message_end": {
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
- case "agent_start":
1000
- p.agentStarts++;
1001
- break;
1002
- case "agent_end": {
1003
- p.agentEnds++;
1004
- const msgs = ev.messages as
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
- p.lastEndWasProcessWait = isParkedToolResult(msgs?.[msgs.length - 1]);
1008
- break;
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
- case "response":
1011
- // RPC command failures (bad model, no API key, …) surface here.
1012
- if (ev.success === false) {
1013
- p.errorMsg = String(ev.error ?? `rpc ${ev.command ?? "command"} failed`);
1014
- }
1015
- break;
1016
- case "error":
1017
- p.errorMsg = String(ev.message ?? ev.error ?? line);
1018
- break;
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
- p.running = p.agentStarts > p.agentEnds;
1022
- p.waitingOnProcess = !p.running && p.agentEnds > 0 && p.lastEndWasProcessWait;
1023
- p.done = !p.running && p.agentEnds > 0 && !p.lastEndWasProcessWait;
1024
- return p;
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): string {
1103
- if (Buffer.byteLength(text, "utf-8") <= PTY_SAFE_MESSAGE_BYTES) return text;
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 `Your full ${kind} is in the file ${file} — read it with the Read tool FIRST, then carry it out exactly as written.`;
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
- const f = writePromptTempFile(opts.agent.name, opts.agent.systemPrompt);
1139
- piArgs.push("--append-system-prompt", f);
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
- await bs(["expect", "-s", id, "--timeout", "30s", '\\{"type"']);
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: ${deliverableMessage("task", opts.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: sent.offset,
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(id: string, isSub: boolean): Promise<string[]> {
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 meta = readMeta(id);
1292
- const logArgs = ["log", "-s", id];
1293
- if (meta?.promptOffset) logArgs.push("--since", String(meta.promptOffset));
1294
- const prog = parseEvents((await bs(logArgs)).stdout);
1295
- if (prog.finalText.trim()) {
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 = prog.errorMsg ? [`⚠ ${prog.errorMsg}`] : [];
1510
+ raw = progress.errorMsg ? [`⚠ ${progress.errorMsg}`] : [];
1301
1511
  }
1302
1512
  }
1303
1513
  return raw
1304
1514
  .map(sanitizeTailLine)
1305
- .filter((l) => l.trim().length > 0)
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 = an agent_end whose last
1347
- // message is NOT a parked babysit_run/process toolResult (that one only means
1348
- // "turn parked, waiting for a background process — pi resumes on its own").
1349
- // Loop: analyze the current-task log slice; done → return; still going →
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
- const lg = await bs(["log", "-s", id, "--since", String(base), "--json"]);
1362
- let text = "";
1363
- let cur = base;
1555
+ let observed: { progress: Progress; offset: number };
1364
1556
  try {
1365
- const j = JSON.parse(lg.stdout);
1366
- text = j.text ?? "";
1367
- cur = j.offset ?? base;
1557
+ observed = taskProgressOf(id);
1368
1558
  } catch {
1369
- text = lg.stdout;
1559
+ observed = { progress: emptyProgress(), offset: readMeta(id)?.promptOffset ?? 0 };
1370
1560
  }
1371
- const prog = parseEvents(text);
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 on the next agent_end.
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":"agent_end"'],
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, TDetails, TState>(
1668
- tool: ToolDefinition<TParams, TDetails, TState>,
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(ctx: ExtensionContext): Promise<void> {
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 { sessions } = await listSessions();
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((s) => s.state === "running");
1775
- const subs = active.filter((s) => kindOf(s.id) === "subagent");
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 doneFlags = await Promise.all(subs.map((s) => isTaskDone(s.id)));
1778
- const idle = doneFlags.filter(Boolean).length;
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((s) => widgetTail(s.id, kindOf(s.id) === "subagent")),
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((s, i) => {
1786
- const isSub = kindOf(s.id) === "subagent";
1787
- const tag = isSub ? (doneById.get(s.id) ? "sub idle" : "sub") : "proc";
1788
- const el = elapsedOf(s.id);
1789
- const header = ` ⏳ ${s.id} [${tag}]${el ? ` ${el}` : ""}`;
1790
- if (tails[i].length === 1) {
1791
- // Single tail line: keep it on the same row as the header.
1792
- lines.push(`${header} │ ${tails[i][0]}`);
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 t of tails[i]) lines.push(` │ ${t}`);
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, "warn");
2105
+ ctx.ui.notify(babysitPreflightError ?? INSTALL_HINT, "warning");
1894
2106
  }
1895
2107
  if (pollTimer) clearInterval(pollTimer);
1896
2108
  pollTimer = setInterval(() => {
1897
- // Skip if the previous (async) poll hasn't finished, so slow babysit
1898
- // calls never stack up.
1899
- if (polling) return;
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
- Promise.all([notifyEndedProcesses(ctx), refreshWidget(ctx)])
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
- /* ignore poll errors */
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's slice ---
2443
- const logArgs = ["log", "-s", params.id];
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: deliverableMessage("steering message", params.text) }
2608
- : { type: "prompt", message: deliverableMessage("task", params.text) };
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
- promptOffset: sent.offset,
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: results.map((r) => `── ${r.id} [${r.kind}] ──\n${r.text}`).join("\n\n"),
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
- (others.length ? ` — still waiting-able: ${others.join(", ")}` : "") +
2749
- `\n\n${first.text}`,
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 = parseEvents((await bs(["log", "-s", picked.id])).stdout);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.3.12",
3
+ "version": "0.3.13",
4
4
  "description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
5
5
  "keywords": [
6
6
  "pi-package",
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 emits `agent_end`, but pi resumes on its own. We
17
- * detect that (last message is a parked toolResult, same rule the parent
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 (see index.ts isParkedToolResult):
40
- // a babysit_run process start stamps NOTIFY_MARKER into its tool result; that
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
- last: { role?: string; toolName?: string; content?: unknown } | undefined,
42
+ messages: { role?: string; toolName?: string; content?: unknown }[] | undefined,
44
43
  ): boolean {
45
- if (!last || last.role !== "toolResult") return false;
46
- if (last.toolName === "process") return true; // legacy pi-processes
47
- if (last.toolName !== "babysit_run") return false;
48
- try {
49
- const s = JSON.stringify(last.content ?? null);
50
- if (s === "null") return true; // unsure — never reap a possible build wait
51
- return s.includes(NOTIFY_MARKER);
52
- } catch {
53
- return true;
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
- cancel(); // supersede any earlier schedule with this end's decision
78
-
79
- const msgs = (
82
+ const messages = (
80
83
  event as { messages?: { role?: string; toolName?: string; content?: unknown }[] }
81
84
  ).messages;
82
- if (isParked(msgs?.[msgs.length - 1])) return;
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
- // Clean exit: babysit sees the child exit and marks the session done,
86
- // so it drops out of the session list/widget instead of idling.
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
  }