@yusukeshib/pi-babysit 0.3.11 → 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/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
  *
@@ -36,10 +36,15 @@ import * as fs from "node:fs";
36
36
  import * as os from "node:os";
37
37
  import * as path from "node:path";
38
38
  import { fileURLToPath } from "node:url";
39
- import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
39
+ import type {
40
+ ExtensionAPI,
41
+ ExtensionContext,
42
+ Theme,
43
+ ToolDefinition,
44
+ } from "@earendil-works/pi-coding-agent";
40
45
  import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
41
46
  import { Box, Markdown, Text } from "@earendil-works/pi-tui";
42
- import { Type } from "typebox";
47
+ import { Type, type TSchema } from "typebox";
43
48
  import { StringEnum } from "@earendil-works/pi-ai";
44
49
  import { type AgentConfig, type AgentScope, discoverAgents } from "./agents";
45
50
 
@@ -127,7 +132,7 @@ export function planSubagentSpawn(
127
132
 
128
133
  // Marker embedded in babysit_run's tool RESULT text for kind=process runs.
129
134
  // It is how "the turn parked awaiting a process-exit notification" is told
130
- // apart from any other turn end (see isParkedToolResult / self-reap.ts).
135
+ // apart from any other turn end (see isParkedMessages / self-reap.ts).
131
136
  export const NOTIFY_MARKER = "[notify-on-exit]";
132
137
 
133
138
  // Human-readable view for the compact subagent JSONL stream when a human
@@ -297,22 +302,11 @@ async function listSessions(): Promise<{ sessions: BsSession[]; error?: string }
297
302
  }
298
303
 
299
304
  async function statusOf(id: string): Promise<BsSession | null> {
300
- // `status --json` shape: { session, status: { state, exit_code, ... } }.
301
- // `note` lives only in `list --json`, so fold it in from there.
302
- const r = await bs(["status", "-s", id, "--json"]);
303
- 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).
304
308
  try {
305
- const parsed = JSON.parse(r.stdout);
306
- const inner = parsed.status ?? parsed;
307
- // `note` and `alive` live only in `list --json`, so fold them in from there
308
- // (alive is what lets us detect a crashed-but-"running" worker).
309
- const listed = (await listSessions()).sessions.find((s) => s.id === id);
310
- return normalizeSession({
311
- id: parsed.session ?? id,
312
- note: listed?.note ?? null,
313
- alive: listed?.alive,
314
- ...inner,
315
- });
309
+ return (await listSessions()).sessions.find((session) => session.id === id) ?? null;
316
310
  } catch {
317
311
  return null;
318
312
  }
@@ -365,12 +359,18 @@ interface Meta {
365
359
  // Temporary reservation while kill is in flight. Unlike `notified`, this
366
360
  // must be cleared on failure so a real completion remains deliverable.
367
361
  notificationPaused?: boolean;
362
+ // Concurrent explicit waits share a reference-counted notification claim.
363
+ // A timeout must not re-enable the poller while another wait still owns it.
364
+ waitReservations?: number;
365
+ waitCompletionClaimed?: boolean;
368
366
  completionObservedAt?: number;
369
367
  startedAt?: number;
370
368
  // subagent
371
369
  task?: string;
372
370
  promptOffset?: number;
373
371
  model?: string;
372
+ tools?: string[];
373
+ messageTempDirs?: Array<{ dir: string; afterAgentEnd: number }>;
374
374
  depth?: number;
375
375
  maxDepth?: number;
376
376
  }
@@ -395,16 +395,52 @@ function readMeta(id: string): Meta | null {
395
395
  }
396
396
  }
397
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
+
398
417
  export function shouldDeliverProcessCompletion(
399
- meta: {
400
- kind: "process" | "subagent";
401
- notified?: boolean;
402
- notificationPaused?: boolean;
403
- } | null,
404
- ): meta is { kind: "process"; notified?: boolean; notificationPaused?: boolean } {
418
+ meta: Meta | null,
419
+ ): meta is Meta & { kind: "process" } {
405
420
  return meta?.kind === "process" && !meta.notified && !meta.notificationPaused;
406
421
  }
407
422
 
423
+ export function shouldDeferCompletionNotification(agentIsIdle: boolean): boolean {
424
+ return !agentIsIdle;
425
+ }
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
+
408
444
  const kindOf = (id: string): "process" | "subagent" => readMeta(id)?.kind ?? "process";
409
445
 
410
446
  // Compact elapsed formatting: "42s", "3m12s", "1h04m".
@@ -442,6 +478,44 @@ async function sendRpc(
442
478
  }
443
479
  }
444
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
+
445
519
  // Wait for `{"type":"response","command":<command>}` after `since`, then parse
446
520
  // it. Distinguishes: success, explicit failure (success:false → the error
447
521
  // message, e.g. "No API key found for …"), subagent death, and timeout — this
@@ -452,7 +526,10 @@ async function rpcResponse(
452
526
  command: string,
453
527
  timeout = "30s",
454
528
  signal?: AbortSignal,
455
- ): 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
+ > {
456
533
  const e = await bs(
457
534
  ["expect", "-s", id, "--since", String(since), "--timeout", timeout, `"command":"${command}"`],
458
535
  { signal },
@@ -476,23 +553,26 @@ async function rpcResponse(
476
553
  : e.stderr || `expect failed (code ${e.code})`,
477
554
  };
478
555
  }
479
- const lg = await bs(["log", "-s", id, "--since", String(since)]);
480
- for (const raw of lg.stdout.split("\n")) {
481
- const line = raw.replace(/\r$/, "").trim();
482
- 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;
483
563
  try {
484
- const ev = JSON.parse(line);
485
- if (ev.type === "response" && ev.command === command) {
486
- if (ev.success === false) {
487
- return { ok: false, error: clip(String(ev.error ?? `${command} failed`)) };
488
- }
489
- 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;
490
568
  }
491
- } catch {
492
- /* partial line */
569
+ } finally {
570
+ fs.closeSync(fd);
493
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)}` };
494
575
  }
495
- return { ok: false, error: `no ${command} response found in log` };
496
576
  }
497
577
 
498
578
  // "5m" / "30s" / "2h" → milliseconds (null = no limit).
@@ -528,14 +608,26 @@ const NOTIFY_COMMAND_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_COMMAND_MAX
528
608
  const NOTIFY_BATCH_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_BATCH_MAX_BYTES", 8_000);
529
609
  const ANSWER_MAX_BYTES = 24_000; // subagent answers / error messages
530
610
 
531
- function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
611
+ export function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
612
+ if (maxBytes <= 0) return "";
532
613
  const buf = Buffer.from(s, "utf8");
533
614
  if (buf.length <= maxBytes) return s;
534
- const half = Math.floor(maxBytes / 2);
535
- // Strip replacement chars from a mid-codepoint cut at the boundary.
536
- const head = buf.subarray(0, half).toString("utf8").replace(/\uFFFD+$/, "");
537
- const tail = buf.subarray(buf.length - half).toString("utf8").replace(/^\uFFFD+/, "");
538
- return `${head}\n… [${buf.length - maxBytes} bytes elided] …\n${tail}`;
615
+
616
+ // The marker counts toward the limit. Recompute a few times because the
617
+ // omitted-byte count can change the marker's digit width.
618
+ let available = maxBytes;
619
+ let marker = "";
620
+ for (let i = 0; i < 3; i++) {
621
+ marker = `\n… [${buf.length - available} bytes elided] …\n`;
622
+ available = Math.max(0, maxBytes - Buffer.byteLength(marker, "utf8"));
623
+ }
624
+ if (Buffer.byteLength(marker, "utf8") > maxBytes) return truncateUtf8End(marker, maxBytes);
625
+ const headBytes = Math.floor(available / 2);
626
+ const tailBytes = available - headBytes;
627
+ // Strip replacement chars from a mid-codepoint cut at either boundary.
628
+ const head = buf.subarray(0, headBytes).toString("utf8").replace(/\uFFFD+$/, "");
629
+ const tail = buf.subarray(buf.length - tailBytes).toString("utf8").replace(/^\uFFFD+/, "");
630
+ return `${head}${marker}${tail}`;
539
631
  }
540
632
 
541
633
  async function searchLog(
@@ -855,19 +947,27 @@ export function deliverProcessCompletionMessage(
855
947
  // (A subagent-profile run does NOT carry the marker: ending a turn to "wait"
856
948
  // for a subagent is a guidance violation, and treating it as completion keeps
857
949
  // the parent from hanging forever.) `process` is the legacy pi-processes tool.
858
- function isParkedToolResult(
859
- last: { role?: string; toolName?: string; content?: unknown } | undefined,
950
+ function isParkedMessages(
951
+ messages: { role?: string; toolName?: string; content?: unknown }[] | undefined,
860
952
  ): boolean {
861
- if (!last || last.role !== "toolResult") return false;
862
- if (last.toolName === "process") return true; // legacy
863
- if (last.toolName !== "babysit_run") return false;
864
- try {
865
- const s = JSON.stringify(last.content ?? null);
866
- if (s === "null") return true; // content unavailable — err on "parked" (never false-kill a build wait)
867
- return s.includes(NOTIFY_MARKER);
868
- } catch {
869
- 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
+ }
870
969
  }
970
+ return false;
871
971
  }
872
972
 
873
973
  // ---------------------------------------------------------------------------
@@ -888,6 +988,7 @@ export interface Progress {
888
988
  // RPC lifecycle bookkeeping (computed over the analyzed log slice):
889
989
  agentStarts: number;
890
990
  agentEnds: number;
991
+ agentSettled: number;
891
992
  lastEndWasProcessWait: boolean;
892
993
  running: boolean; // an agent run is in flight right now
893
994
  waitingOnProcess: boolean; // idle, but a process resume is pending
@@ -921,82 +1022,158 @@ function summarizeToolCall(name: string, args: Record<string, unknown>): string
921
1022
  }
922
1023
  }
923
1024
 
924
- export function parseEvents(logText: string): Progress {
925
- const p: Progress = {
1025
+ function emptyProgress(): Progress {
1026
+ return {
926
1027
  turns: 0,
927
1028
  toolCalls: [],
928
1029
  finalText: "",
929
1030
  agentStarts: 0,
930
1031
  agentEnds: 0,
1032
+ agentSettled: 0,
931
1033
  lastEndWasProcessWait: false,
932
1034
  running: false,
933
1035
  waitingOnProcess: false,
934
1036
  done: false,
935
1037
  };
936
- for (const raw of logText.split("\n")) {
937
- const line = raw.replace(/\r$/, "").trim();
938
- if (!line.startsWith("{")) continue;
939
- let ev: Record<string, unknown>;
940
- try {
941
- ev = JSON.parse(line);
942
- } catch {
943
- 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;
944
1073
  }
945
- switch (ev.type) {
946
- case "turn_start":
947
- p.turns++;
948
- break;
949
- case "tool_execution_start": {
950
- const name = String(ev.toolName ?? "tool");
951
- p.toolCalls.push({
952
- name,
953
- summary: summarizeToolCall(name, (ev.args as Record<string, unknown>) ?? {}),
954
- });
955
- break;
956
- }
957
- case "message_end": {
958
- const msg = ev.message as
959
- | { role?: string; content?: { type: string; text?: string }[]; usage?: { totalTokens?: number; cost?: { total?: number } } }
960
- | undefined;
961
- if (msg?.role === "assistant") {
962
- const txt = (msg.content ?? [])
963
- .filter((c) => c.type === "text" && c.text)
964
- .map((c) => c.text)
965
- .join("");
966
- if (txt.trim()) p.finalText = txt; // keep the latest non-empty assistant text
967
- if (msg.usage) {
968
- p.tokens = msg.usage.totalTokens;
969
- p.cost = msg.usage.cost?.total;
970
- }
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;
971
1087
  }
972
- break;
973
1088
  }
974
- case "agent_start":
975
- p.agentStarts++;
976
- break;
977
- case "agent_end": {
978
- p.agentEnds++;
979
- 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
980
1098
  | { role?: string; toolName?: string; content?: unknown }[]
981
- | undefined;
982
- p.lastEndWasProcessWait = isParkedToolResult(msgs?.[msgs.length - 1]);
983
- 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`);
984
1109
  }
985
- case "response":
986
- // RPC command failures (bad model, no API key, …) surface here.
987
- if (ev.success === false) {
988
- p.errorMsg = String(ev.error ?? `rpc ${ev.command ?? "command"} failed`);
989
- }
990
- break;
991
- case "error":
992
- p.errorMsg = String(ev.message ?? ev.error ?? line);
993
- 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);
994
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);
995
1170
  }
996
- p.running = p.agentStarts > p.agentEnds;
997
- p.waitingOnProcess = !p.running && p.agentEnds > 0 && p.lastEndWasProcessWait;
998
- p.done = !p.running && p.agentEnds > 0 && !p.lastEndWasProcessWait;
999
- 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
+ };
1000
1177
  }
1001
1178
 
1002
1179
  // ---------------------------------------------------------------------------
@@ -1073,13 +1250,32 @@ function writePromptTempFile(agentName: string, prompt: string): string {
1073
1250
  // Anything over this budget is written to a file instead, and only a short
1074
1251
  // "read this file" instruction travels through the PTY.
1075
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
+ }
1076
1260
 
1077
- function deliverableMessage(kind: "task" | "steering message", text: string): string {
1078
- 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 };
1079
1263
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-babysit-msg-"));
1080
1264
  const file = path.join(dir, "message.md");
1081
1265
  fs.writeFileSync(file, text, "utf-8");
1082
- 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
+ }
1083
1279
  }
1084
1280
 
1085
1281
  interface SubagentOpts {
@@ -1107,12 +1303,29 @@ async function spawnSubagent(
1107
1303
  const model = opts.model ?? opts.agent?.model;
1108
1304
  if (model) piArgs.push("--model", model);
1109
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
+ }
1110
1313
  if (tools && tools.length > 0) piArgs.push("--tools", tools.join(","));
1111
1314
  piArgs.push("--append-system-prompt", SUBAGENT_GUIDANCE);
1315
+ let promptTempFile: string | undefined;
1112
1316
  if (opts.agent?.systemPrompt?.trim()) {
1113
- const f = writePromptTempFile(opts.agent.name, opts.agent.systemPrompt);
1114
- piArgs.push("--append-system-prompt", f);
1317
+ promptTempFile = writePromptTempFile(opts.agent.name, opts.agent.systemPrompt);
1318
+ piArgs.push("--append-system-prompt", promptTempFile);
1115
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
+ };
1116
1329
  // Self-reaper: a finished subagent exits after a short idle grace instead of
1117
1330
  // lingering until the absolute --timeout. Cancelled by a follow-up task, and
1118
1331
  // it never reaps a turn parked on a process-exit notification. See self-reap.ts.
@@ -1153,12 +1366,14 @@ async function spawnSubagent(
1153
1366
  },
1154
1367
  });
1155
1368
  if (r.code !== 0) {
1369
+ cleanupPromptTemp();
1156
1370
  return { error: r.stderr || r.stdout || `babysit run failed (exit ${r.code}, no output) — check that \`${BABYSIT_BIN}\` works and ${ROOT} is writable` };
1157
1371
  }
1158
1372
  let id: string;
1159
1373
  try {
1160
1374
  id = JSON.parse(r.stdout).id;
1161
1375
  } catch {
1376
+ cleanupPromptTemp();
1162
1377
  return { error: `could not parse id from: ${r.stdout}` };
1163
1378
  }
1164
1379
 
@@ -1175,12 +1390,25 @@ async function spawnSubagent(
1175
1390
  });
1176
1391
 
1177
1392
  // Wait for pi to boot (first JSON event in the log), then inject the task.
1178
- 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);
1179
1406
  const sent = await sendRpc(id, {
1180
1407
  type: "prompt",
1181
- message: `Task: ${deliverableMessage("task", opts.task)}`,
1408
+ message: `Task: ${delivery.message}`,
1182
1409
  });
1183
1410
  if ("error" in sent) {
1411
+ discardDelivery(delivery);
1184
1412
  await bs(["kill", "-s", id]);
1185
1413
  return { error: `could not send task to subagent ${id}: ${sent.error}` };
1186
1414
  }
@@ -1188,6 +1416,7 @@ async function spawnSubagent(
1188
1416
  // and similar config errors surface — fail the spawn loudly).
1189
1417
  const resp = await rpcResponse(id, sent.offset, "prompt", "60s");
1190
1418
  if (!resp.ok) {
1419
+ discardDelivery(delivery);
1191
1420
  await bs(["kill", "-s", id]);
1192
1421
  return { error: `subagent ${id} rejected the task: ${resp.error}` };
1193
1422
  }
@@ -1200,6 +1429,7 @@ async function spawnSubagent(
1200
1429
  if (st.ok && st.data) {
1201
1430
  const m = (st.data as { model?: { id?: string } | null }).model;
1202
1431
  if (m === null) {
1432
+ discardDelivery(delivery);
1203
1433
  await bs(["kill", "-s", id]);
1204
1434
  return {
1205
1435
  error: `subagent ${id} has no usable model${opts.model ? ` (requested "${opts.model}")` : ""} — check the model name with \`pi --list-models\`.`,
@@ -1212,8 +1442,12 @@ async function spawnSubagent(
1212
1442
  writeMeta(id, {
1213
1443
  kind: "subagent",
1214
1444
  task: opts.task,
1215
- promptOffset: sent.offset,
1445
+ promptOffset: resp.offset,
1216
1446
  model: resolvedModel,
1447
+ tools,
1448
+ messageTempDirs: delivery.tempDir
1449
+ ? [{ dir: delivery.tempDir, afterAgentEnd: 1 }]
1450
+ : undefined,
1217
1451
  depth: opts.depth,
1218
1452
  maxDepth: opts.maxDepth,
1219
1453
  startedAt: Date.now(),
@@ -1257,43 +1491,31 @@ function sanitizeTailLine(s: string): string {
1257
1491
  // Trailing lines to show for a running session (sanitized, unprefixed).
1258
1492
  // process → raw log tail; subagent → derived activity (recent tool calls /
1259
1493
  // partial answer), since its log is RPC JSONL, not human-readable.
1260
- 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[]> {
1261
1499
  let raw: string[];
1262
1500
  if (!isSub) {
1263
1501
  const out = (await bs(["log", "-s", id, "--tail", String(WIDGET_TAIL_LINES)])).stdout;
1264
1502
  raw = out.split("\n");
1265
1503
  } else {
1266
- const meta = readMeta(id);
1267
- const logArgs = ["log", "-s", id];
1268
- if (meta?.promptOffset) logArgs.push("--since", String(meta.promptOffset));
1269
- const prog = parseEvents((await bs(logArgs)).stdout);
1270
- if (prog.finalText.trim()) {
1271
- raw = prog.finalText.trim().split("\n");
1272
- } else if (prog.toolCalls.length > 0) {
1273
- 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);
1274
1509
  } else {
1275
- raw = prog.errorMsg ? [`⚠ ${prog.errorMsg}`] : [];
1510
+ raw = progress.errorMsg ? [`⚠ ${progress.errorMsg}`] : [];
1276
1511
  }
1277
1512
  }
1278
1513
  return raw
1279
1514
  .map(sanitizeTailLine)
1280
- .filter((l) => l.trim().length > 0)
1515
+ .filter((line) => line.trim().length > 0)
1281
1516
  .slice(-WIDGET_TAIL_LINES);
1282
1517
  }
1283
1518
 
1284
- // Classify a running subagent as busy or idle by analyzing the current task's
1285
- // log slice (same logic babysit_check uses).
1286
- async function isTaskDone(id: string): Promise<boolean> {
1287
- try {
1288
- const meta = readMeta(id);
1289
- const logArgs = ["log", "-s", id];
1290
- if (meta?.promptOffset) logArgs.push("--since", String(meta.promptOffset));
1291
- return parseEvents((await bs(logArgs)).stdout).done;
1292
- } catch {
1293
- return false; // when unsure, treat as busy — never hide a live worker
1294
- }
1295
- }
1296
-
1297
1519
  // ---------------------------------------------------------------------------
1298
1520
  // human take-over hint (no tmux dependency)
1299
1521
  // ---------------------------------------------------------------------------
@@ -1318,32 +1540,25 @@ interface WaitOutcome {
1318
1540
  progress?: Progress;
1319
1541
  }
1320
1542
 
1321
- // Wait for ONE subagent's current task. Completion = an agent_end whose last
1322
- // message is NOT a parked babysit_run/process toolResult (that one only means
1323
- // "turn parked, waiting for a background process — pi resumes on its own").
1324
- // Loop: analyze the current-task log slice; done → return; still going →
1325
- // 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.
1326
1547
  async function waitForTask(
1327
1548
  id: string,
1328
1549
  limitMs: number | null,
1329
1550
  signal?: AbortSignal,
1330
1551
  ): Promise<WaitOutcome> {
1331
- const meta = readMeta(id);
1332
- const base = meta?.promptOffset ?? 0;
1333
1552
  const t0 = Date.now();
1334
1553
 
1335
1554
  for (;;) {
1336
- const lg = await bs(["log", "-s", id, "--since", String(base), "--json"]);
1337
- let text = "";
1338
- let cur = base;
1555
+ let observed: { progress: Progress; offset: number };
1339
1556
  try {
1340
- const j = JSON.parse(lg.stdout);
1341
- text = j.text ?? "";
1342
- cur = j.offset ?? base;
1557
+ observed = taskProgressOf(id);
1343
1558
  } catch {
1344
- text = lg.stdout;
1559
+ observed = { progress: emptyProgress(), offset: readMeta(id)?.promptOffset ?? 0 };
1345
1560
  }
1346
- const prog = parseEvents(text);
1561
+ const { progress: prog, offset: cur } = observed;
1347
1562
  const st = await statusOf(id);
1348
1563
 
1349
1564
  const stats =
@@ -1400,7 +1615,8 @@ async function waitForTask(
1400
1615
  progress: prog,
1401
1616
  });
1402
1617
 
1403
- // 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.
1404
1620
  let expectTimeout = "0"; // indefinite
1405
1621
  if (limitMs != null) {
1406
1622
  const remaining = limitMs - (Date.now() - t0);
@@ -1408,7 +1624,7 @@ async function waitForTask(
1408
1624
  expectTimeout = `${Math.ceil(remaining / 1000)}s`;
1409
1625
  }
1410
1626
  const e = await bs(
1411
- ["expect", "-s", id, "--since", String(cur), "--timeout", expectTimeout, '"type":"agent_end"'],
1627
+ ["expect", "-s", id, "--since", String(cur), "--timeout", expectTimeout, '"type":"agent_settled"'],
1412
1628
  { signal },
1413
1629
  );
1414
1630
  if (signal?.aborted || e.code === 130) {
@@ -1442,19 +1658,47 @@ function suppressNotify(id: string, reason: "observed" | "kill" = "observed"): v
1442
1658
  }
1443
1659
  }
1444
1660
 
1445
- export function canRestoreNotificationAfterWait(meta: {
1661
+ export interface WaitReservationState {
1446
1662
  notified?: boolean;
1447
1663
  killNotificationSuppressed?: boolean;
1448
- }): boolean {
1449
- return meta.notified === true && meta.killNotificationSuppressed !== true;
1664
+ waitReservations?: number;
1665
+ waitCompletionClaimed?: boolean;
1450
1666
  }
1451
1667
 
1452
- function enableNotify(id: string): void {
1453
- const meta = readMeta(id);
1454
- if (meta && meta.kind === "process" && canRestoreNotificationAfterWait(meta)) {
1455
- meta.notified = false;
1456
- writeMeta(id, meta);
1668
+ export function canRestoreNotificationAfterWait(meta: WaitReservationState): boolean {
1669
+ return (
1670
+ meta.notified === true &&
1671
+ meta.killNotificationSuppressed !== true &&
1672
+ meta.waitCompletionClaimed !== true &&
1673
+ (meta.waitReservations ?? 0) === 0
1674
+ );
1675
+ }
1676
+
1677
+ export function transitionWaitReservation(
1678
+ state: WaitReservationState,
1679
+ action: "reserve" | "abandon" | "claim",
1680
+ ): WaitReservationState {
1681
+ const next = { ...state };
1682
+ if (action === "reserve") {
1683
+ next.waitReservations = (next.waitReservations ?? 0) + 1;
1684
+ next.notified = true;
1685
+ return next;
1457
1686
  }
1687
+
1688
+ next.waitReservations = Math.max(0, (next.waitReservations ?? 0) - 1);
1689
+ if (action === "claim") next.waitCompletionClaimed = true;
1690
+ if (action === "claim" || next.waitCompletionClaimed) {
1691
+ next.notified = true;
1692
+ } else if (canRestoreNotificationAfterWait(next)) {
1693
+ next.notified = false;
1694
+ }
1695
+ return next;
1696
+ }
1697
+
1698
+ function updateWaitReservation(id: string, action: "reserve" | "abandon" | "claim"): void {
1699
+ const meta = readMeta(id);
1700
+ if (!meta || meta.kind !== "process") return;
1701
+ writeMeta(id, { ...meta, ...transitionWaitReservation(meta, action) });
1458
1702
  }
1459
1703
 
1460
1704
  function pauseNotify(id: string): void {
@@ -1508,19 +1752,20 @@ async function waitForExit(
1508
1752
  }
1509
1753
  // fall through: session exited before the pattern appeared
1510
1754
  } else {
1511
- // An explicit wait owns completion delivery. Mark it before blocking so
1512
- // the exit poller cannot race us and inject a duplicate notification.
1513
- suppressNotify(id);
1755
+ // An explicit wait owns completion delivery. Reference-count the claim so
1756
+ // one concurrent wait timing out cannot re-enable notifications underneath
1757
+ // another wait that is still pending.
1758
+ updateWaitReservation(id, "reserve");
1514
1759
  const w = await bs(["wait", "-s", id, "--timeout", t], { signal });
1515
1760
  if (signal?.aborted || w.code === 130) {
1516
- enableNotify(id);
1761
+ updateWaitReservation(id, "abandon");
1517
1762
  return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
1518
1763
  }
1519
1764
  if (w.code === 124) {
1520
1765
  // 124 is ambiguous (timeout vs child exiting 124) — disambiguate.
1521
1766
  const st0 = await statusOf(id);
1522
1767
  if (st0?.state === "running") {
1523
- enableNotify(id);
1768
+ updateWaitReservation(id, "abandon");
1524
1769
  return {
1525
1770
  id,
1526
1771
  kind: "timeout",
@@ -1534,9 +1779,15 @@ async function waitForExit(
1534
1779
 
1535
1780
  const st = await statusOf(id);
1536
1781
  if (!st) {
1782
+ // A non-pattern backend wait returned before this lookup, so preserve its
1783
+ // completion claim when status persistence is transiently unavailable.
1784
+ // Abandoning here can re-enable the poller and duplicate a completion the
1785
+ // explicit wait already consumed. This matches the old suppress-first rule.
1786
+ if (!expectPattern) updateWaitReservation(id, "claim");
1537
1787
  return { id, kind: "exited", ok: false, text: `No such session: ${id}` };
1538
1788
  }
1539
- suppressNotify(id); // the agent sees the exit here; don't notify again
1789
+ if (expectPattern) suppressNotify(id);
1790
+ else updateWaitReservation(id, "claim"); // the agent sees the exit here; don't notify again
1540
1791
  const meta = readMeta(id);
1541
1792
  const workerDead = st.state === "dead" && st.exit_code == null;
1542
1793
  const ok = st.exit_code === 0;
@@ -1588,12 +1839,42 @@ export function isAllowedDirectBash(_command: string): boolean {
1588
1839
  return process.env.PI_BABYSIT_ALLOW_BASH === "1";
1589
1840
  }
1590
1841
 
1842
+ export function activeToolsWithoutDirectBash(activeTools: string[], allowDirectBash: boolean): string[] {
1843
+ return allowDirectBash ? activeTools : activeTools.filter((name) => name !== "bash");
1844
+ }
1845
+
1591
1846
  // ---------------------------------------------------------------------------
1592
1847
  // extension
1593
1848
  // ---------------------------------------------------------------------------
1594
1849
 
1595
1850
  export default function (pi: ExtensionAPI) {
1596
1851
  let pollTimer: ReturnType<typeof setInterval> | undefined;
1852
+ let pollNeeded = true;
1853
+ const declaredToolErrors = new Set<string>();
1854
+
1855
+ // Pi only persists custom-tool failures when they are thrown or patched by a
1856
+ // tool_result hook; an `isError` property returned from execute() is ignored.
1857
+ // Keep the structured result (status, log path, diagnostics) while promoting
1858
+ // its declared error bit at the supported hook boundary.
1859
+ const registerTool = <TParams extends TSchema>(
1860
+ tool: ToolDefinition<TParams, unknown, unknown>,
1861
+ ): void => {
1862
+ const execute = tool.execute;
1863
+ pi.registerTool({
1864
+ ...tool,
1865
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
1866
+ const result = await execute(toolCallId, params, signal, onUpdate, ctx);
1867
+ if ((result as typeof result & { isError?: boolean }).isError === true) {
1868
+ declaredToolErrors.add(toolCallId);
1869
+ }
1870
+ return result;
1871
+ },
1872
+ });
1873
+ };
1874
+
1875
+ pi.on("tool_result", (event) => {
1876
+ if (declaredToolErrors.delete(event.toolCallId)) return { isError: true };
1877
+ });
1597
1878
 
1598
1879
  // Exit notifications for kind=process sessions: the poller detects
1599
1880
  // running→exited transitions and injects ONE message (triggerTurn) for all
@@ -1601,8 +1882,15 @@ export default function (pi: ExtensionAPI) {
1601
1882
  // that ended its turn after babysit_run without spending one turn per exit.
1602
1883
  // Kills via babysit_kill and exits already reported by babysit_wait are
1603
1884
  // suppressed via meta.notified.
1604
- async function notifyEndedProcesses(): Promise<void> {
1605
- const { sessions } = await listSessions();
1885
+ async function notifyEndedProcesses(
1886
+ ctx: ExtensionContext,
1887
+ snapshot?: BsSession[],
1888
+ ): Promise<void> {
1889
+ // Never steer a completion into an active agent turn. In particular, this
1890
+ // lets an immediately-following babysit_wait reserve the completion first,
1891
+ // instead of racing the poller and receiving both wait + auto notification.
1892
+ if (shouldDeferCompletionNotification(ctx.isIdle())) return;
1893
+ const sessions = snapshot ?? (await listSessions()).sessions;
1606
1894
  const ready: Array<{ session: BsSession; meta: Meta }> = [];
1607
1895
  for (const session of sessions) {
1608
1896
  if (session.state === "running") continue;
@@ -1660,6 +1948,9 @@ export default function (pi: ExtensionAPI) {
1660
1948
  metadataById.set(notice.id, current);
1661
1949
  return [{ ...notice, command: current.command }];
1662
1950
  });
1951
+ // Output collection above yields to the event loop. Re-check idleness so a
1952
+ // newly-started agent turn cannot receive a duplicate completion mid-turn.
1953
+ if (shouldDeferCompletionNotification(ctx.isIdle())) return;
1663
1954
  deliverProcessCompletionMessage(
1664
1955
  notices,
1665
1956
  (message, options) => pi.sendMessage(message, options),
@@ -1673,30 +1964,45 @@ export default function (pi: ExtensionAPI) {
1673
1964
  );
1674
1965
  }
1675
1966
 
1676
- const refreshWidget = async (ctx: ExtensionContext) => {
1967
+ const refreshWidget = async (ctx: ExtensionContext, snapshot?: BsSession[]) => {
1677
1968
  if (!ctx.hasUI) return;
1678
- const active = (await listSessions()).sessions.filter((s) => s.state === "running");
1679
- 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");
1680
1973
  const procs = active.length - subs.length;
1681
- const doneFlags = await Promise.all(subs.map((s) => isTaskDone(s.id)));
1682
- 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;
1683
1983
  const lines = renderWidgetLines(procs, subs.length - idle, idle);
1684
- // Per-session elapsed time (live: refreshed every poll tick).
1685
- const doneById = new Map(subs.map((s, i) => [s.id, doneFlags[i]]));
1686
1984
  const tails = await Promise.all(
1687
- 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
+ }),
1688
1993
  );
1689
- active.forEach((s, i) => {
1690
- const isSub = kindOf(s.id) === "subagent";
1691
- const tag = isSub ? (doneById.get(s.id) ? "sub idle" : "sub") : "proc";
1692
- const el = elapsedOf(s.id);
1693
- const header = ` ⏳ ${s.id} [${tag}]${el ? ` ${el}` : ""}`;
1694
- if (tails[i].length === 1) {
1695
- // Single tail line: keep it on the same row as the header.
1696
- 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]}`);
1697
2003
  } else {
1698
2004
  lines.push(header);
1699
- for (const t of tails[i]) lines.push(` │ ${t}`);
2005
+ for (const tail of tails[index]) lines.push(` │ ${tail}`);
1700
2006
  }
1701
2007
  });
1702
2008
  ctx.ui.setWidget("pi-babysit", lines, { placement: "belowEditor" });
@@ -1773,6 +2079,16 @@ export default function (pi: ExtensionAPI) {
1773
2079
 
1774
2080
  let polling = false;
1775
2081
  pi.on("session_start", async (_event, ctx) => {
2082
+ // Do not expose the built-in bash tool only to reject it after the model has
2083
+ // already paid for a failed tool turn. The tool_call hook remains a fallback
2084
+ // if another extension/preset re-enables bash later in the session.
2085
+ const activeTools = pi.getActiveTools();
2086
+ const supervisedTools = activeToolsWithoutDirectBash(
2087
+ activeTools,
2088
+ isAllowedDirectBash(""),
2089
+ );
2090
+ if (supervisedTools.length !== activeTools.length) pi.setActiveTools(supervisedTools);
2091
+
1776
2092
  // Session-local registry: scope the babysit root to this pi session so
1777
2093
  // other sessions' processes/subagents are invisible here. Resuming a
1778
2094
  // session keeps the same id, so its sessions come back with it.
@@ -1781,20 +2097,36 @@ export default function (pi: ExtensionAPI) {
1781
2097
  } catch {
1782
2098
  ROOT = ROOT_BASE;
1783
2099
  }
2100
+ taskProgressCache.clear();
2101
+ pollNeeded = true;
1784
2102
  // Warn early if the binary is missing so the user isn't surprised only when
1785
2103
  // a tool later fails. Tools/commands still enforce it via requireBabysit.
1786
2104
  if (ctx.hasUI && !(await babysitAvailable())) {
1787
- ctx.ui.notify(babysitPreflightError ?? INSTALL_HINT, "warn");
2105
+ ctx.ui.notify(babysitPreflightError ?? INSTALL_HINT, "warning");
1788
2106
  }
1789
2107
  if (pollTimer) clearInterval(pollTimer);
1790
2108
  pollTimer = setInterval(() => {
1791
- // Skip if the previous (async) poll hasn't finished, so slow babysit
1792
- // calls never stack up.
1793
- 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;
1794
2113
  polling = true;
1795
- Promise.all([notifyEndedProcesses(), 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
+ })()
1796
2127
  .catch(() => {
1797
- /* ignore poll errors */
2128
+ // Keep polling armed after a transient CLI/filesystem error.
2129
+ pollNeeded = true;
1798
2130
  })
1799
2131
  .finally(() => {
1800
2132
  polling = false;
@@ -1829,7 +2161,7 @@ export default function (pi: ExtensionAPI) {
1829
2161
  });
1830
2162
 
1831
2163
  // ----- babysit_run --------------------------------------------------------
1832
- pi.registerTool({
2164
+ registerTool({
1833
2165
  name: "babysit_run",
1834
2166
  label: "Babysit: run",
1835
2167
  description:
@@ -1850,7 +2182,8 @@ export default function (pi: ExtensionAPI) {
1850
2182
  "Run any shell command with context-safe captured output; quick commands return metadata, longer ones continue in background",
1851
2183
  promptGuidelines: [
1852
2184
  "Use babysit_run as the default for shell commands, not only long-running work. Small output is returned directly; large stdout/stderr stays out of model context in the returned log path. Give meaningful commands a clear stable `name`.",
1853
- "Inspect a babysit log with babysit_check { id, lines, pattern? }; never read or cat a potentially large log file in full.",
2185
+ "Bundle closely related tiny observations into one babysit_run command when that reduces tool turns without obscuring lifecycle or failure handling.",
2186
+ "Inspect a babysit log with babysit_check { id, lines, pattern? }; never read or cat a potentially large log file in full. Prefer a targeted `pattern` search over returning a broad tail.",
1854
2187
  "After babysit_run { command } starts a process, end your response immediately so the automatic process-end notification can resume you; NEVER poll with babysit_check or sleep. Set continueAfterStart: true only when you have immediate, specific, non-polling work to do next. Call babysit_wait when you must consume the result inside the current turn (optionally with `expect` to wait for a readiness line like 'listening on').",
1855
2188
  "If a babysit worker is killed externally, babysit_run reports it as worker-dead rather than hanging. Set retryOnWorkerDeath: true only for safe, idempotent commands; it retries at most once and may otherwise duplicate side effects.",
1856
2189
  "babysit_run gives full PTY control: drive interactive programs (installers, wizards, REPLs) with babysit_send (text or named keys) and read the rendered screen with babysit_check { screen: true }.",
@@ -1984,6 +2317,7 @@ export default function (pi: ExtensionAPI) {
1984
2317
  details: {},
1985
2318
  };
1986
2319
  }
2320
+ pollNeeded = true;
1987
2321
  await refreshWidget(ctx);
1988
2322
 
1989
2323
  // One-shot / non-interactive mode (e.g. `pi -p`): there is no live
@@ -2128,6 +2462,7 @@ export default function (pi: ExtensionAPI) {
2128
2462
  };
2129
2463
  }
2130
2464
 
2465
+ pollNeeded = true;
2131
2466
  await refreshWidget(ctx);
2132
2467
  return {
2133
2468
  content: [
@@ -2199,7 +2534,7 @@ export default function (pi: ExtensionAPI) {
2199
2534
  });
2200
2535
 
2201
2536
  // ----- babysit_check ------------------------------------------------------
2202
- pi.registerTool({
2537
+ registerTool({
2203
2538
  name: "babysit_check",
2204
2539
  label: "Babysit: check",
2205
2540
  description:
@@ -2257,7 +2592,7 @@ export default function (pi: ExtensionAPI) {
2257
2592
  const preview = what.length > 60 ? `${what.slice(0, 57)}…` : what;
2258
2593
  return `${s.id} [${kind}] ${s.state}${ec}${depth}${flag}${preview ? ` — ${preview}` : ""}`;
2259
2594
  });
2260
- return { content: [{ type: "text", text: lines.join("\n") }], details: { sessions } };
2595
+ return { content: [{ type: "text", text: clip(lines.join("\n")) }], details: { sessions } };
2261
2596
  }
2262
2597
 
2263
2598
  const st = await statusOf(params.id);
@@ -2299,7 +2634,7 @@ export default function (pi: ExtensionAPI) {
2299
2634
  ? `--- latest matches /${params.pattern}/ ---\n${result.text}`
2300
2635
  : `(no output matching /${params.pattern}/)`;
2301
2636
  return {
2302
- content: [{ type: "text", text: `${header}\n${body}` }],
2637
+ content: [{ type: "text", text: clip(`${header}\n${body}`) }],
2303
2638
  details: { status: st, kind, logPath: logPath(params.id), pattern: params.pattern },
2304
2639
  };
2305
2640
  }
@@ -2327,15 +2662,13 @@ export default function (pi: ExtensionAPI) {
2327
2662
  parts.push(tail ? `--- recent output ---\n${tail}` : "(no output yet)");
2328
2663
  }
2329
2664
  return {
2330
- content: [{ type: "text", text: parts.join("\n") }],
2665
+ content: [{ type: "text", text: clip(parts.join("\n")) }],
2331
2666
  details: { status: st, kind: "process", logPath: logPath(params.id) },
2332
2667
  };
2333
2668
  }
2334
2669
 
2335
- // --- subagent: analyze only the current task's slice ---
2336
- const logArgs = ["log", "-s", params.id];
2337
- if (meta?.promptOffset) logArgs.push("--since", String(meta.promptOffset));
2338
- const prog = parseEvents((await bs(logArgs)).stdout);
2670
+ // --- subagent: analyze only bytes appended for the current task ---
2671
+ const prog = taskProgressOf(params.id).progress;
2339
2672
  const nTools = Math.min(Math.max(1, params.tools ?? 8), 50);
2340
2673
  const recent = prog.toolCalls.slice(-nTools);
2341
2674
 
@@ -2382,14 +2715,14 @@ export default function (pi: ExtensionAPI) {
2382
2715
  }
2383
2716
 
2384
2717
  return {
2385
- content: [{ type: "text", text: parts.join("\n") }],
2718
+ content: [{ type: "text", text: clip(parts.join("\n")) }],
2386
2719
  details: { status: st, progress: prog, kind: "subagent" },
2387
2720
  };
2388
2721
  },
2389
2722
  });
2390
2723
 
2391
2724
  // ----- babysit_send -------------------------------------------------------
2392
- pi.registerTool({
2725
+ registerTool({
2393
2726
  name: "babysit_send",
2394
2727
  label: "Babysit: send",
2395
2728
  description:
@@ -2484,6 +2817,18 @@ export default function (pi: ExtensionAPI) {
2484
2817
  details: {},
2485
2818
  };
2486
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
+ }
2487
2832
  let mode = params.mode ?? "auto";
2488
2833
  if (mode === "auto") {
2489
2834
  // isStreaming tells us whether an agent run is in flight right now.
@@ -2495,12 +2840,27 @@ export default function (pi: ExtensionAPI) {
2495
2840
  }
2496
2841
  mode = streaming ? "steer" : "task";
2497
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
+ );
2498
2857
  const cmd =
2499
2858
  mode === "steer"
2500
- ? { type: "steer", message: deliverableMessage("steering message", params.text) }
2501
- : { type: "prompt", message: deliverableMessage("task", params.text) };
2859
+ ? { type: "steer", message: delivery.message }
2860
+ : { type: "prompt", message: delivery.message };
2502
2861
  const sent = await sendRpc(params.id, cmd);
2503
2862
  if ("error" in sent) {
2863
+ discardDelivery(delivery);
2504
2864
  return {
2505
2865
  content: [{ type: "text", text: sent.error }],
2506
2866
  isError: true,
@@ -2509,6 +2869,7 @@ export default function (pi: ExtensionAPI) {
2509
2869
  }
2510
2870
  const resp = await rpcResponse(params.id, sent.offset, cmd.type, "15s");
2511
2871
  if (!resp.ok) {
2872
+ discardDelivery(delivery);
2512
2873
  return {
2513
2874
  content: [{ type: "text", text: `${cmd.type} was not accepted: ${resp.error}` }],
2514
2875
  isError: true,
@@ -2520,11 +2881,25 @@ export default function (pi: ExtensionAPI) {
2520
2881
  writeMeta(params.id, {
2521
2882
  kind: "subagent",
2522
2883
  task: params.text,
2523
- 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,
2524
2887
  model: meta?.model,
2888
+ tools: meta?.tools,
2889
+ messageTempDirs: delivery.tempDir
2890
+ ? [{ dir: delivery.tempDir, afterAgentEnd: deliveryCleanupAfter }]
2891
+ : undefined,
2525
2892
  depth: meta?.depth,
2526
2893
  maxDepth: meta?.maxDepth,
2527
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
+ });
2528
2903
  }
2529
2904
  return {
2530
2905
  content: [
@@ -2542,7 +2917,7 @@ export default function (pi: ExtensionAPI) {
2542
2917
  });
2543
2918
 
2544
2919
  // ----- babysit_wait -------------------------------------------------------
2545
- pi.registerTool({
2920
+ registerTool({
2546
2921
  name: "babysit_wait",
2547
2922
  label: "Babysit: wait",
2548
2923
  description:
@@ -2587,6 +2962,11 @@ export default function (pi: ExtensionAPI) {
2587
2962
  };
2588
2963
  }
2589
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
+ }
2590
2970
 
2591
2971
  if (ids.length === 1) {
2592
2972
  const r = await waitFor(ids[0], limitMs, signal, params.expect);
@@ -2612,7 +2992,10 @@ export default function (pi: ExtensionAPI) {
2612
2992
  content: [
2613
2993
  {
2614
2994
  type: "text",
2615
- 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
+ ),
2616
2999
  },
2617
3000
  ],
2618
3001
  isError: !ok,
@@ -2636,10 +3019,12 @@ export default function (pi: ExtensionAPI) {
2636
3019
  content: [
2637
3020
  {
2638
3021
  type: "text",
2639
- text:
3022
+ text: clip(
2640
3023
  `First to finish: ${first.id} [${first.kind}]` +
2641
- (others.length ? ` — still waiting-able: ${others.join(", ")}` : "") +
2642
- `\n\n${first.text}`,
3024
+ (others.length ? ` — still waiting-able: ${others.join(", ")}` : "") +
3025
+ `\n\n${first.text}`,
3026
+ ANSWER_MAX_BYTES,
3027
+ ),
2643
3028
  },
2644
3029
  ],
2645
3030
  isError: !first.ok,
@@ -2653,7 +3038,7 @@ export default function (pi: ExtensionAPI) {
2653
3038
  });
2654
3039
 
2655
3040
  // ----- babysit_kill -------------------------------------------------------
2656
- pi.registerTool({
3041
+ registerTool({
2657
3042
  name: "babysit_kill",
2658
3043
  label: "Babysit: kill",
2659
3044
  description: "Terminate a babysit session (process or subagent).",
@@ -2753,7 +3138,7 @@ export default function (pi: ExtensionAPI) {
2753
3138
  // Inline snapshot (running) or summary (finished) — no tmux window.
2754
3139
  if (kind === "subagent") {
2755
3140
  // Parse the RPC event stream and show the final answer, not raw JSONL.
2756
- const prog = parseEvents((await bs(["log", "-s", picked.id])).stdout);
3141
+ const prog = taskProgressOf(picked.id).progress;
2757
3142
  const stats =
2758
3143
  `turns=${prog.turns} tools=${prog.toolCalls.length}` +
2759
3144
  (prog.tokens != null ? ` ctx=${prog.tokens}` : "") +