@yusukeshib/pi-babysit 0.3.12 → 0.3.14

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
  *
@@ -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
@@ -301,28 +301,25 @@ async function listSessions(): Promise<{ sessions: BsSession[]; error?: string }
301
301
  }
302
302
  }
303
303
 
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;
304
+ async function lookupStatus(
305
+ id: string,
306
+ ): Promise<{ session: BsSession | null; error?: string }> {
307
+ // `list --json` already carries every lifecycle field used by the extension,
308
+ // including `alive` and `note`. Using it directly avoids the old
309
+ // status-then-list pair (two CLI subprocesses for every status lookup).
309
310
  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
- });
321
- } catch {
322
- return null;
311
+ const listed = await listSessions();
312
+ if (listed.error) return { session: null, error: listed.error };
313
+ return { session: listed.sessions.find((session) => session.id === id) ?? null };
314
+ } catch (error) {
315
+ return { session: null, error: error instanceof Error ? error.message : String(error) };
323
316
  }
324
317
  }
325
318
 
319
+ async function statusOf(id: string): Promise<BsSession | null> {
320
+ return (await lookupStatus(id)).session;
321
+ }
322
+
326
323
  export function isConfirmedTerminalState(state: string): boolean {
327
324
  return state === "killed" || state === "exited";
328
325
  }
@@ -358,11 +355,19 @@ async function awaitConfirmedTermination(id: string): Promise<BsSession | null>
358
355
  // kind=process: name/command + `notified` (exit notification dedup).
359
356
  // kind=subagent: task + the raw-log byte offset of the last prompt, which lets
360
357
  // check/wait analyze only the CURRENT task's events (important for follow-ups).
358
+ export interface SubagentBudget {
359
+ maxCost?: number;
360
+ maxTurns?: number;
361
+ maxToolCalls?: number;
362
+ maxUsageTokens?: number;
363
+ }
364
+
361
365
  interface Meta {
362
366
  kind: "process" | "subagent";
363
367
  // process
364
368
  name?: string;
365
369
  command?: string;
370
+ notificationGroup?: string;
366
371
  notified?: boolean;
367
372
  // A confirmed kill permanently owns completion delivery. An interrupted
368
373
  // concurrent wait must not re-enable the automatic notification afterward.
@@ -380,8 +385,14 @@ interface Meta {
380
385
  task?: string;
381
386
  promptOffset?: number;
382
387
  model?: string;
388
+ tools?: string[];
389
+ messageTempDirs?: Array<{ dir: string; afterAgentEnd: number }>;
383
390
  depth?: number;
384
391
  maxDepth?: number;
392
+ budget?: SubagentBudget;
393
+ budgetExceededAt?: number;
394
+ budgetReason?: string;
395
+ budgetKilled?: boolean;
385
396
  }
386
397
 
387
398
  const metaDir = () => path.join(ROOT, "meta");
@@ -404,20 +415,287 @@ function readMeta(id: string): Meta | null {
404
415
  }
405
416
  }
406
417
 
418
+ export interface BabysitGcResult {
419
+ candidates: string[];
420
+ deleted: string[];
421
+ bytes: number;
422
+ skippedLive: string[];
423
+ }
424
+
425
+ function processIsAlive(pid: number): boolean {
426
+ try {
427
+ process.kill(pid, 0);
428
+ return true;
429
+ } catch (error) {
430
+ return (error as NodeJS.ErrnoException).code === "EPERM";
431
+ }
432
+ }
433
+
434
+ const GC_LOCK_FILE = ".pi-babysit-gc.lock";
435
+ const ACTIVE_LEASE_PREFIX = ".pi-babysit-active-";
436
+
437
+ function scanTreeStats(root: string): { bytes: number; newestMtimeMs: number } {
438
+ let bytes = 0;
439
+ let newestMtimeMs = 0;
440
+ const pending = [root];
441
+ while (pending.length > 0) {
442
+ const current = pending.pop() as string;
443
+ let entries: fs.Dirent[];
444
+ try {
445
+ entries = fs.readdirSync(current, { withFileTypes: true });
446
+ } catch {
447
+ continue;
448
+ }
449
+ for (const entry of entries) {
450
+ if (entry.name === GC_LOCK_FILE) continue;
451
+ const fullPath = path.join(current, entry.name);
452
+ try {
453
+ const stat = fs.lstatSync(fullPath);
454
+ newestMtimeMs = Math.max(newestMtimeMs, stat.mtimeMs);
455
+ if (entry.isDirectory()) pending.push(fullPath);
456
+ else if (entry.isFile()) bytes += stat.size;
457
+ } catch {
458
+ /* raced with cleanup */
459
+ }
460
+ }
461
+ }
462
+ return { bytes, newestMtimeMs };
463
+ }
464
+
465
+ function gcRootIsSafe(root: string): boolean {
466
+ let rootEntries: fs.Dirent[];
467
+ try {
468
+ rootEntries = fs.readdirSync(root, { withFileTypes: true });
469
+ } catch {
470
+ return false;
471
+ }
472
+ for (const entry of rootEntries) {
473
+ if (!entry.isFile() || !entry.name.startsWith(ACTIVE_LEASE_PREFIX)) continue;
474
+ try {
475
+ const lease = JSON.parse(fs.readFileSync(path.join(root, entry.name), "utf8")) as {
476
+ pid?: number;
477
+ };
478
+ if (!Number.isSafeInteger(lease.pid) || (lease.pid as number) <= 0) return false;
479
+ if (processIsAlive(lease.pid as number)) return false;
480
+ } catch {
481
+ return false;
482
+ }
483
+ }
484
+
485
+ const sessionsDir = path.join(root, "sessions");
486
+ let sessionDirs: fs.Dirent[];
487
+ try {
488
+ sessionDirs = fs.readdirSync(sessionsDir, { withFileTypes: true });
489
+ } catch {
490
+ return false;
491
+ }
492
+ let sawStatus = false;
493
+ for (const sessionEntry of sessionDirs) {
494
+ if (!sessionEntry.isDirectory()) continue;
495
+ const sessionDir = path.join(sessionsDir, sessionEntry.name);
496
+ try {
497
+ const status = JSON.parse(
498
+ fs.readFileSync(path.join(sessionDir, "status.json"), "utf8"),
499
+ ) as { state?: string; child_pid?: number | null };
500
+ sawStatus = true;
501
+ if (status.state !== "running") {
502
+ if (!status.state || (!isConfirmedTerminalState(status.state) && status.state !== "dead")) {
503
+ return false;
504
+ }
505
+ continue;
506
+ }
507
+ const supervisorPid = Number(
508
+ (JSON.parse(fs.readFileSync(path.join(sessionDir, "meta.json"), "utf8")) as {
509
+ babysit_pid?: number;
510
+ }).babysit_pid,
511
+ );
512
+ const childPid = Number(status.child_pid);
513
+ if (
514
+ !Number.isSafeInteger(supervisorPid) ||
515
+ supervisorPid <= 0 ||
516
+ processIsAlive(supervisorPid) ||
517
+ (Number.isSafeInteger(childPid) && childPid > 0 && processIsAlive(childPid))
518
+ ) {
519
+ return false;
520
+ }
521
+ } catch {
522
+ return false;
523
+ }
524
+ }
525
+ return sawStatus;
526
+ }
527
+
528
+ function acquireRootLease(root: string): string | null {
529
+ fs.mkdirSync(root, { recursive: true });
530
+ const leasePath = path.join(
531
+ root,
532
+ `${ACTIVE_LEASE_PREFIX}${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
533
+ );
534
+ const sleeper = new Int32Array(new SharedArrayBuffer(4));
535
+ for (let attempt = 0; attempt < 100; attempt++) {
536
+ if (fs.existsSync(path.join(root, GC_LOCK_FILE))) {
537
+ Atomics.wait(sleeper, 0, 0, 50);
538
+ continue;
539
+ }
540
+ try {
541
+ fs.writeFileSync(leasePath, JSON.stringify({ pid: process.pid, startedAt: Date.now() }), {
542
+ flag: "wx",
543
+ });
544
+ if (!fs.existsSync(path.join(root, GC_LOCK_FILE))) return leasePath;
545
+ fs.rmSync(leasePath, { force: true });
546
+ } catch {
547
+ /* retry while a collector owns the namespace */
548
+ }
549
+ Atomics.wait(sleeper, 0, 0, 50);
550
+ }
551
+ return null;
552
+ }
553
+
554
+ function releaseRootLease(leasePath: string | undefined): void {
555
+ if (!leasePath) return;
556
+ try {
557
+ fs.rmSync(leasePath, { force: true });
558
+ } catch {
559
+ /* best-effort */
560
+ }
561
+ }
562
+
563
+ export function gcBabysitRoots(options: {
564
+ rootBase: string;
565
+ currentRoot: string;
566
+ olderThanMs: number;
567
+ dryRun?: boolean;
568
+ now?: number;
569
+ }): BabysitGcResult {
570
+ const now = options.now ?? Date.now();
571
+ const current = path.resolve(options.currentRoot);
572
+ const result: BabysitGcResult = { candidates: [], deleted: [], bytes: 0, skippedLive: [] };
573
+ let roots: fs.Dirent[];
574
+ try {
575
+ roots = fs.readdirSync(options.rootBase, { withFileTypes: true });
576
+ } catch {
577
+ return result;
578
+ }
579
+
580
+ for (const entry of roots) {
581
+ if (!entry.isDirectory() || entry.name.startsWith(".pi-babysit-gc-")) continue;
582
+ const root = path.join(options.rootBase, entry.name);
583
+ if (path.resolve(root) === current) continue;
584
+ if (!gcRootIsSafe(root)) {
585
+ result.skippedLive.push(entry.name);
586
+ continue;
587
+ }
588
+ const stats = scanTreeStats(root);
589
+ if (now - stats.newestMtimeMs < options.olderThanMs) continue;
590
+ result.candidates.push(entry.name);
591
+ if (options.dryRun !== false) {
592
+ result.bytes += stats.bytes;
593
+ continue;
594
+ }
595
+
596
+ const lockPath = path.join(root, GC_LOCK_FILE);
597
+ let lockFd: number | undefined;
598
+ let tombstone: string | undefined;
599
+ try {
600
+ lockFd = fs.openSync(lockPath, "wx");
601
+ // Compatible Pi processes acquire an active lease around this same lock.
602
+ // Revalidate after locking, then atomically rename the root so a resume
603
+ // racing deletion creates a fresh namespace instead of writing into rmSync.
604
+ if (!gcRootIsSafe(root)) {
605
+ result.skippedLive.push(entry.name);
606
+ continue;
607
+ }
608
+ const refreshedStats = scanTreeStats(root);
609
+ if (now - refreshedStats.newestMtimeMs < options.olderThanMs) continue;
610
+ tombstone = path.join(
611
+ options.rootBase,
612
+ `.pi-babysit-gc-${entry.name}-${process.pid}-${Date.now()}`,
613
+ );
614
+ fs.closeSync(lockFd);
615
+ lockFd = undefined;
616
+ fs.renameSync(root, tombstone);
617
+ fs.rmSync(tombstone, { recursive: true, force: true });
618
+ result.deleted.push(entry.name);
619
+ result.bytes += refreshedStats.bytes;
620
+ } catch {
621
+ /* report only roots whose atomic removal completed */
622
+ } finally {
623
+ if (lockFd != null) {
624
+ try {
625
+ fs.closeSync(lockFd);
626
+ } catch {
627
+ /* best-effort */
628
+ }
629
+ }
630
+ try {
631
+ fs.rmSync(lockPath, { force: true });
632
+ } catch {
633
+ /* root may already have been atomically renamed */
634
+ }
635
+ }
636
+ }
637
+ return result;
638
+ }
639
+
640
+ function cleanupMessageTempDirs(id: string, meta: Meta, agentEnds: number): void {
641
+ if (!meta.messageTempDirs?.length) return;
642
+ const keep: NonNullable<Meta["messageTempDirs"]> = [];
643
+ for (const entry of meta.messageTempDirs) {
644
+ if (entry.afterAgentEnd > agentEnds) {
645
+ keep.push(entry);
646
+ continue;
647
+ }
648
+ try {
649
+ fs.rmSync(entry.dir, { recursive: true, force: true });
650
+ } catch {
651
+ /* best-effort */
652
+ }
653
+ }
654
+ if (keep.length > 0) meta.messageTempDirs = keep;
655
+ else delete meta.messageTempDirs;
656
+ writeMeta(id, meta);
657
+ }
658
+
407
659
  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 } {
660
+ meta: Meta | null,
661
+ ): meta is Meta & { kind: "process" } {
414
662
  return meta?.kind === "process" && !meta.notified && !meta.notificationPaused;
415
663
  }
416
664
 
665
+ export function isNotificationGroupReady(
666
+ meta: Meta,
667
+ sessions: BsSession[],
668
+ metaFor: (id: string) => Meta | null,
669
+ ): boolean {
670
+ if (!meta.notificationGroup) return true;
671
+ return !sessions.some(
672
+ (session) =>
673
+ session.state === "running" &&
674
+ metaFor(session.id)?.notificationGroup === meta.notificationGroup,
675
+ );
676
+ }
677
+
417
678
  export function shouldDeferCompletionNotification(agentIsIdle: boolean): boolean {
418
679
  return !agentIsIdle;
419
680
  }
420
681
 
682
+ export function shouldKeepPolling(
683
+ sessions: Array<{ id: string; state: string }>,
684
+ metaFor: (id: string) => Meta | null,
685
+ ): boolean {
686
+ return sessions.some(
687
+ (session) =>
688
+ session.state === "running" || shouldDeliverProcessCompletion(metaFor(session.id)),
689
+ );
690
+ }
691
+
692
+ export function shouldKeepPollingAfterList(
693
+ listed: { sessions: Array<{ id: string; state: string }>; error?: string },
694
+ metaFor: (id: string) => Meta | null,
695
+ ): boolean {
696
+ return Boolean(listed.error) || shouldKeepPolling(listed.sessions, metaFor);
697
+ }
698
+
421
699
  const kindOf = (id: string): "process" | "subagent" => readMeta(id)?.kind ?? "process";
422
700
 
423
701
  // Compact elapsed formatting: "42s", "3m12s", "1h04m".
@@ -455,6 +733,44 @@ async function sendRpc(
455
733
  }
456
734
  }
457
735
 
736
+ export function parseRpcResponseBytes(
737
+ bytes: Buffer,
738
+ since: number,
739
+ command: string,
740
+ ):
741
+ | { ok: true; data?: Record<string, unknown>; offset: number }
742
+ | { ok: false; error: string } {
743
+ let start = 0;
744
+ while (start < bytes.length) {
745
+ const newline = bytes.indexOf(0x0a, start);
746
+ const end = newline < 0 ? bytes.length : newline + 1;
747
+ const line = bytes
748
+ .subarray(start, newline < 0 ? end : newline)
749
+ .toString("utf8")
750
+ .replace(/\r$/, "")
751
+ .trim();
752
+ if (line.startsWith("{")) {
753
+ try {
754
+ const event = JSON.parse(line);
755
+ if (event.type === "response" && event.command === command) {
756
+ if (event.success === false) {
757
+ return { ok: false, error: clip(String(event.error ?? `${command} failed`)) };
758
+ }
759
+ return {
760
+ ok: true,
761
+ data: event.data as Record<string, unknown> | undefined,
762
+ offset: since + end,
763
+ };
764
+ }
765
+ } catch {
766
+ /* partial line */
767
+ }
768
+ }
769
+ start = end;
770
+ }
771
+ return { ok: false, error: `no ${command} response found in log` };
772
+ }
773
+
458
774
  // Wait for `{"type":"response","command":<command>}` after `since`, then parse
459
775
  // it. Distinguishes: success, explicit failure (success:false → the error
460
776
  // message, e.g. "No API key found for …"), subagent death, and timeout — this
@@ -465,7 +781,10 @@ async function rpcResponse(
465
781
  command: string,
466
782
  timeout = "30s",
467
783
  signal?: AbortSignal,
468
- ): Promise<{ ok: true; data?: Record<string, unknown> } | { ok: false; error: string }> {
784
+ ): Promise<
785
+ | { ok: true; data?: Record<string, unknown>; offset: number }
786
+ | { ok: false; error: string }
787
+ > {
469
788
  const e = await bs(
470
789
  ["expect", "-s", id, "--since", String(since), "--timeout", timeout, `"command":"${command}"`],
471
790
  { signal },
@@ -473,12 +792,19 @@ async function rpcResponse(
473
792
  if (e.code !== 0) {
474
793
  const st = await statusOf(id);
475
794
  if (st && st.state !== "running") {
476
- const tail = clip((await bs(["log", "-s", id, "--tail", "15"])).stdout.trim());
795
+ let structuredError = "";
796
+ try {
797
+ const bytes = fs.readFileSync(logPath(id));
798
+ structuredError = parseEvents(bytes.subarray(Math.min(since, bytes.length)).toString("utf8")).errorMsg ?? "";
799
+ } catch {
800
+ /* full log path below remains the diagnostic source */
801
+ }
477
802
  return {
478
803
  ok: false,
479
804
  error:
480
805
  `subagent exited (exit_code=${st.exit_code ?? "?"}) before responding to ${command}.` +
481
- (tail ? `\nLast output:\n${tail}` : ""),
806
+ (structuredError ? `\n${structuredError}` : "") +
807
+ `\nFull log: ${logPath(id)}`,
482
808
  };
483
809
  }
484
810
  return {
@@ -489,23 +815,26 @@ async function rpcResponse(
489
815
  : e.stderr || `expect failed (code ${e.code})`,
490
816
  };
491
817
  }
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;
818
+ try {
819
+ const file = logPath(id);
820
+ const size = fs.statSync(file).size;
821
+ const length = Math.max(0, size - since);
822
+ const bytes = Buffer.allocUnsafe(length);
823
+ const fd = fs.openSync(file, "r");
824
+ let read = 0;
496
825
  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 };
826
+ while (read < length) {
827
+ const count = fs.readSync(fd, bytes, read, length - read, since + read);
828
+ if (count === 0) break;
829
+ read += count;
503
830
  }
504
- } catch {
505
- /* partial line */
831
+ } finally {
832
+ fs.closeSync(fd);
506
833
  }
834
+ return parseRpcResponseBytes(bytes.subarray(0, read), since, command);
835
+ } catch (error) {
836
+ return { ok: false, error: `could not read ${command} response: ${String(error)}` };
507
837
  }
508
- return { ok: false, error: `no ${command} response found in log` };
509
838
  }
510
839
 
511
840
  // "5m" / "30s" / "2h" → milliseconds (null = no limit).
@@ -539,7 +868,10 @@ const INLINE_OUTPUT_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_INLINE_OUTPUT_MAX_B
539
868
  const NOTIFY_OUTPUT_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_OUTPUT_MAX_BYTES", 2_000);
540
869
  const NOTIFY_COMMAND_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_COMMAND_MAX_BYTES", 240);
541
870
  const NOTIFY_BATCH_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_BATCH_MAX_BYTES", 8_000);
542
- const ANSWER_MAX_BYTES = 24_000; // subagent answers / error messages
871
+ const ANSWER_MAX_BYTES = 24_000; // single subagent answers / structured error messages
872
+ const MAX_MULTI_WAIT_SESSIONS = 32;
873
+ const SUBAGENT_BUDGET_GRACE_MS =
874
+ parseDurMs(process.env.PI_BABYSIT_BUDGET_GRACE ?? "30s") ?? 30_000;
543
875
 
544
876
  export function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
545
877
  if (maxBytes <= 0) return "";
@@ -563,6 +895,13 @@ export function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
563
895
  return `${head}${marker}${tail}`;
564
896
  }
565
897
 
898
+ export function clipMultiWaitResult(
899
+ value: string,
900
+ maxBytes = INLINE_OUTPUT_MAX_BYTES,
901
+ ): string {
902
+ return clip(value, Math.min(maxBytes, ANSWER_MAX_BYTES));
903
+ }
904
+
566
905
  async function searchLog(
567
906
  id: string,
568
907
  pattern: string,
@@ -880,19 +1219,42 @@ export function deliverProcessCompletionMessage(
880
1219
  // (A subagent-profile run does NOT carry the marker: ending a turn to "wait"
881
1220
  // for a subagent is a guidance violation, and treating it as completion keeps
882
1221
  // the parent from hanging forever.) `process` is the legacy pi-processes tool.
883
- function isParkedToolResult(
884
- last: { role?: string; toolName?: string; content?: unknown } | undefined,
1222
+ function isParkedMessages(
1223
+ messages:
1224
+ | {
1225
+ role?: string;
1226
+ toolName?: string;
1227
+ content?: unknown;
1228
+ details?: { kind?: string; status?: string };
1229
+ }[]
1230
+ | undefined,
885
1231
  ): 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;
1232
+ if (!messages) return false;
1233
+ // Models sometimes add a short assistant note after starting a process. Scan
1234
+ // the run rather than requiring the marker-bearing tool result to be the last
1235
+ // message, otherwise that harmless note turns a parked build into false task
1236
+ // completion (and lets the self-reaper kill it).
1237
+ for (let i = messages.length - 1; i >= 0; i--) {
1238
+ const message = messages[i];
1239
+ if (message?.role !== "toolResult") continue;
1240
+ if (message.toolName === "process") return true; // legacy pi-processes
1241
+ if (message.toolName !== "babysit_run") continue;
1242
+ if (message.details?.kind === "process" && message.details.status === "started") {
1243
+ return true;
1244
+ }
1245
+ const text = Array.isArray(message.content)
1246
+ ? message.content
1247
+ .filter((part): part is { type: "text"; text: string } =>
1248
+ Boolean(part && typeof part === "object" && part.type === "text" && typeof part.text === "string"),
1249
+ )
1250
+ .map((part) => part.text)
1251
+ .join("")
1252
+ : typeof message.content === "string"
1253
+ ? message.content
1254
+ : "";
1255
+ if (/^Process started \(id: [^)]+\)\. \[notify-on-exit\]\nLog: /.test(text)) return true;
895
1256
  }
1257
+ return false;
896
1258
  }
897
1259
 
898
1260
  // ---------------------------------------------------------------------------
@@ -907,12 +1269,22 @@ export interface Progress {
907
1269
  turns: number;
908
1270
  toolCalls: ToolCall[];
909
1271
  finalText: string;
1272
+ /** Context size reported by the most recent assistant response. */
910
1273
  tokens?: number;
911
- cost?: number;
1274
+ /** Cumulative model usage for the current subagent task. */
1275
+ modelCalls: number;
1276
+ usageTokens: number;
1277
+ inputTokens: number;
1278
+ outputTokens: number;
1279
+ cacheReadTokens: number;
1280
+ cacheWriteTokens: number;
1281
+ reasoningTokens: number;
1282
+ cost: number;
912
1283
  errorMsg?: string;
913
1284
  // RPC lifecycle bookkeeping (computed over the analyzed log slice):
914
1285
  agentStarts: number;
915
1286
  agentEnds: number;
1287
+ agentSettled: number;
916
1288
  lastEndWasProcessWait: boolean;
917
1289
  running: boolean; // an agent run is in flight right now
918
1290
  waitingOnProcess: boolean; // idle, but a process resume is pending
@@ -946,82 +1318,268 @@ function summarizeToolCall(name: string, args: Record<string, unknown>): string
946
1318
  }
947
1319
  }
948
1320
 
949
- export function parseEvents(logText: string): Progress {
950
- const p: Progress = {
1321
+ function emptyProgress(): Progress {
1322
+ return {
951
1323
  turns: 0,
952
1324
  toolCalls: [],
953
1325
  finalText: "",
1326
+ modelCalls: 0,
1327
+ usageTokens: 0,
1328
+ inputTokens: 0,
1329
+ outputTokens: 0,
1330
+ cacheReadTokens: 0,
1331
+ cacheWriteTokens: 0,
1332
+ reasoningTokens: 0,
1333
+ cost: 0,
954
1334
  agentStarts: 0,
955
1335
  agentEnds: 0,
1336
+ agentSettled: 0,
956
1337
  lastEndWasProcessWait: false,
957
1338
  running: false,
958
1339
  waitingOnProcess: false,
959
1340
  done: false,
960
1341
  };
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.
1342
+ }
1343
+
1344
+ export function subagentBudgetViolation(
1345
+ progress: Progress,
1346
+ budget?: SubagentBudget,
1347
+ ): string | null {
1348
+ if (!budget) return null;
1349
+ if (budget.maxCost != null && progress.cost >= budget.maxCost) {
1350
+ return `cost $${progress.cost.toFixed(4)} reached maxCost $${budget.maxCost.toFixed(4)}`;
1351
+ }
1352
+ if (budget.maxTurns != null && progress.turns >= budget.maxTurns) {
1353
+ return `${progress.turns} turns reached maxTurns ${budget.maxTurns}`;
1354
+ }
1355
+ if (budget.maxToolCalls != null && progress.toolCalls.length >= budget.maxToolCalls) {
1356
+ return `${progress.toolCalls.length} tool calls reached maxToolCalls ${budget.maxToolCalls}`;
1357
+ }
1358
+ if (budget.maxUsageTokens != null && progress.usageTokens >= budget.maxUsageTokens) {
1359
+ return `${progress.usageTokens} usage tokens reached maxUsageTokens ${budget.maxUsageTokens}`;
1360
+ }
1361
+ return null;
1362
+ }
1363
+
1364
+ export function subagentBudgetAction(
1365
+ progress: Progress,
1366
+ budget: SubagentBudget | undefined,
1367
+ exceededAt: number | undefined,
1368
+ now: number,
1369
+ graceMs: number,
1370
+ ): { action: "none" | "steer" | "kill"; reason?: string } {
1371
+ const reason = subagentBudgetViolation(progress, budget);
1372
+ if (!reason) return { action: "none" };
1373
+ if (exceededAt == null) return { action: "steer", reason };
1374
+ return now - exceededAt >= graceMs
1375
+ ? { action: "kill", reason }
1376
+ : { action: "none", reason };
1377
+ }
1378
+
1379
+ function updateProgressState(progress: Progress): Progress {
1380
+ progress.running = progress.agentStarts > progress.agentEnds;
1381
+ progress.waitingOnProcess =
1382
+ !progress.running && progress.agentEnds > 0 && progress.lastEndWasProcessWait;
1383
+ // `agent_end` is not final: Pi may still retry, compact-and-retry, or process
1384
+ // queued continuations. `agent_settled` is the authoritative completion event.
1385
+ progress.done =
1386
+ !progress.running &&
1387
+ progress.agentSettled > 0 &&
1388
+ !progress.lastEndWasProcessWait;
1389
+ return progress;
1390
+ }
1391
+
1392
+ function parseEventLine(progress: Progress, raw: string): void {
1393
+ const line = raw.replace(/\r$/, "").trim();
1394
+ if (!line.startsWith("{")) return;
1395
+ let event: Record<string, unknown>;
1396
+ try {
1397
+ event = JSON.parse(line);
1398
+ } catch {
1399
+ return; // partial trailing line, etc.
1400
+ }
1401
+ switch (event.type) {
1402
+ case "turn_start":
1403
+ progress.turns++;
1404
+ break;
1405
+ case "tool_execution_start": {
1406
+ const name = String(event.toolName ?? "tool");
1407
+ progress.toolCalls.push({
1408
+ name,
1409
+ summary: summarizeToolCall(name, (event.args as Record<string, unknown>) ?? {}),
1410
+ });
1411
+ break;
969
1412
  }
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;
1413
+ case "message_end": {
1414
+ const message = event.message as
1415
+ | {
1416
+ role?: string;
1417
+ content?: { type: string; text?: string }[];
1418
+ usage?: {
1419
+ input?: number;
1420
+ output?: number;
1421
+ cacheRead?: number;
1422
+ cacheWrite?: number;
1423
+ reasoning?: number;
1424
+ totalTokens?: number;
1425
+ cost?: { total?: number };
1426
+ };
995
1427
  }
1428
+ | undefined;
1429
+ if (message?.role === "assistant") {
1430
+ const text = (message.content ?? [])
1431
+ .filter((content) => content.type === "text" && content.text)
1432
+ .map((content) => content.text)
1433
+ .join("");
1434
+ if (text.trim()) progress.finalText = text;
1435
+ if (message.usage) {
1436
+ const finite = (value: number | undefined) =>
1437
+ typeof value === "number" && Number.isFinite(value) ? value : 0;
1438
+ progress.modelCalls++;
1439
+ progress.tokens = message.usage.totalTokens;
1440
+ progress.usageTokens += finite(message.usage.totalTokens);
1441
+ progress.inputTokens += finite(message.usage.input);
1442
+ progress.outputTokens += finite(message.usage.output);
1443
+ progress.cacheReadTokens += finite(message.usage.cacheRead);
1444
+ progress.cacheWriteTokens += finite(message.usage.cacheWrite);
1445
+ progress.reasoningTokens += finite(message.usage.reasoning);
1446
+ progress.cost += finite(message.usage.cost?.total);
996
1447
  }
997
- break;
998
1448
  }
999
- case "agent_start":
1000
- p.agentStarts++;
1001
- break;
1002
- case "agent_end": {
1003
- p.agentEnds++;
1004
- const msgs = ev.messages as
1005
- | { role?: string; toolName?: string; content?: unknown }[]
1006
- | undefined;
1007
- p.lastEndWasProcessWait = isParkedToolResult(msgs?.[msgs.length - 1]);
1008
- break;
1449
+ break;
1450
+ }
1451
+ case "agent_start":
1452
+ progress.agentStarts++;
1453
+ break;
1454
+ case "agent_end": {
1455
+ progress.agentEnds++;
1456
+ progress.lastEndWasProcessWait =
1457
+ typeof event.piBabysitParked === "boolean"
1458
+ ? event.piBabysitParked
1459
+ : isParkedMessages(
1460
+ event.messages as
1461
+ | {
1462
+ role?: string;
1463
+ toolName?: string;
1464
+ content?: unknown;
1465
+ details?: { kind?: string; status?: string };
1466
+ }[]
1467
+ | undefined,
1468
+ );
1469
+ break;
1470
+ }
1471
+ case "agent_settled":
1472
+ progress.agentSettled++;
1473
+ break;
1474
+ case "response":
1475
+ if (event.success === false) {
1476
+ progress.errorMsg = String(event.error ?? `rpc ${event.command ?? "command"} failed`);
1009
1477
  }
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;
1478
+ break;
1479
+ case "error":
1480
+ progress.errorMsg = String(event.message ?? event.error ?? line);
1481
+ break;
1482
+ case "extension_error": {
1483
+ const extension = event.extensionPath ? `${String(event.extensionPath)}: ` : "";
1484
+ progress.errorMsg = `${extension}${String(event.error ?? "extension failed")}`;
1485
+ break;
1019
1486
  }
1020
1487
  }
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;
1488
+ }
1489
+
1490
+ export function parseEvents(logText: string): Progress {
1491
+ const progress = emptyProgress();
1492
+ for (const line of logText.split("\n")) parseEventLine(progress, line);
1493
+ return updateProgressState(progress);
1494
+ }
1495
+
1496
+ export function buildSubagentDoneResult(
1497
+ progress: Progress,
1498
+ ): { body: string; ok: boolean } {
1499
+ const finalText = progress.finalText.trim();
1500
+ return {
1501
+ body: clip(
1502
+ progress.errorMsg
1503
+ ? `Extension error: ${progress.errorMsg}` +
1504
+ (finalText ? `\n\n--- final answer ---\n${finalText}` : "")
1505
+ : finalText || "(no answer text)",
1506
+ ANSWER_MAX_BYTES,
1507
+ ),
1508
+ ok: !progress.errorMsg,
1509
+ };
1510
+ }
1511
+
1512
+ export function buildSubagentExitDiagnostic(
1513
+ progress: Progress,
1514
+ fullLogPath: string,
1515
+ ): string {
1516
+ const body = clip(
1517
+ progress.errorMsg ||
1518
+ progress.finalText.trim() ||
1519
+ "(no structured error was emitted; inspect the full log)",
1520
+ ANSWER_MAX_BYTES,
1521
+ );
1522
+ return `${body}\n\nFull log: ${fullLogPath}`;
1523
+ }
1524
+
1525
+ interface TaskProgressCache {
1526
+ base: number;
1527
+ offset: number;
1528
+ pending: Buffer;
1529
+ progress: Progress;
1530
+ }
1531
+ const taskProgressCache = new Map<string, TaskProgressCache>();
1532
+
1533
+ /** Parse only bytes appended since the previous observation of this task. */
1534
+ function taskProgressOf(id: string): { progress: Progress; offset: number } {
1535
+ const base = readMeta(id)?.promptOffset ?? 0;
1536
+ const file = logPath(id);
1537
+ const size = fs.statSync(file).size;
1538
+ let cached = taskProgressCache.get(id);
1539
+ if (!cached || cached.base !== base || size < cached.offset) {
1540
+ cached = { base, offset: base, pending: Buffer.alloc(0), progress: emptyProgress() };
1541
+ taskProgressCache.set(id, cached);
1542
+ }
1543
+ if (size > cached.offset) {
1544
+ const added = Buffer.allocUnsafe(size - cached.offset);
1545
+ const fd = fs.openSync(file, "r");
1546
+ let read = 0;
1547
+ try {
1548
+ while (read < added.length) {
1549
+ const count = fs.readSync(fd, added, read, added.length - read, cached.offset + read);
1550
+ if (count === 0) break;
1551
+ read += count;
1552
+ }
1553
+ } finally {
1554
+ fs.closeSync(fd);
1555
+ }
1556
+ cached.offset += read;
1557
+ const bytes = cached.pending.length
1558
+ ? Buffer.concat([cached.pending, added.subarray(0, read)])
1559
+ : added.subarray(0, read);
1560
+ let start = 0;
1561
+ for (let index = 0; index < bytes.length; index++) {
1562
+ if (bytes[index] !== 0x0a) continue;
1563
+ parseEventLine(cached.progress, bytes.subarray(start, index).toString("utf8"));
1564
+ start = index + 1;
1565
+ }
1566
+ cached.pending = Buffer.from(bytes.subarray(start));
1567
+ }
1568
+ updateProgressState(cached.progress);
1569
+ const currentMeta = readMeta(id);
1570
+ if (currentMeta?.budgetKilled && currentMeta.budgetReason) {
1571
+ cached.progress.errorMsg = `Subagent budget exceeded: ${currentMeta.budgetReason}`;
1572
+ }
1573
+ if (cached.progress.agentEnds > 0) {
1574
+ const meta = currentMeta;
1575
+ if (meta) cleanupMessageTempDirs(id, meta, cached.progress.agentEnds);
1576
+ }
1577
+ // If the writer was observed mid-record, let `babysit expect --since` start
1578
+ // before that partial line so a split agent_settled marker cannot be missed.
1579
+ return {
1580
+ progress: cached.progress,
1581
+ offset: cached.offset - cached.pending.length,
1582
+ };
1025
1583
  }
1026
1584
 
1027
1585
  // ---------------------------------------------------------------------------
@@ -1045,6 +1603,7 @@ interface ProcOpts {
1045
1603
  timeout?: string; // default: none — dev servers may run indefinitely
1046
1604
  idleTimeout?: string;
1047
1605
  pty: boolean;
1606
+ notificationGroup?: string;
1048
1607
  }
1049
1608
 
1050
1609
  async function spawnProcess(opts: ProcOpts): Promise<{ id: string } | { error: string }> {
@@ -1074,6 +1633,7 @@ async function spawnProcess(opts: ProcOpts): Promise<{ id: string } | { error: s
1074
1633
  kind: "process",
1075
1634
  name: opts.name ?? id,
1076
1635
  command: opts.command,
1636
+ notificationGroup: opts.notificationGroup,
1077
1637
  notified: false,
1078
1638
  startedAt: Date.now(),
1079
1639
  });
@@ -1098,13 +1658,32 @@ function writePromptTempFile(agentName: string, prompt: string): string {
1098
1658
  // Anything over this budget is written to a file instead, and only a short
1099
1659
  // "read this file" instruction travels through the PTY.
1100
1660
  const PTY_SAFE_MESSAGE_BYTES = 600;
1661
+ const messageNeedsReadTool = (text: string): boolean =>
1662
+ Buffer.byteLength(text, "utf-8") > PTY_SAFE_MESSAGE_BYTES;
1663
+
1664
+ interface DeliverableMessage {
1665
+ message: string;
1666
+ tempDir?: string;
1667
+ }
1101
1668
 
1102
- function deliverableMessage(kind: "task" | "steering message", text: string): string {
1103
- if (Buffer.byteLength(text, "utf-8") <= PTY_SAFE_MESSAGE_BYTES) return text;
1669
+ function deliverableMessage(kind: "task" | "steering message", text: string): DeliverableMessage {
1670
+ if (!messageNeedsReadTool(text)) return { message: text };
1104
1671
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-babysit-msg-"));
1105
1672
  const file = path.join(dir, "message.md");
1106
1673
  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.`;
1674
+ return {
1675
+ message: `Your full ${kind} is in the file ${file} — read it with the Read tool FIRST, then carry it out exactly as written.`,
1676
+ tempDir: dir,
1677
+ };
1678
+ }
1679
+
1680
+ function discardDelivery(delivery: DeliverableMessage): void {
1681
+ if (!delivery.tempDir) return;
1682
+ try {
1683
+ fs.rmSync(delivery.tempDir, { recursive: true, force: true });
1684
+ } catch {
1685
+ /* best-effort */
1686
+ }
1108
1687
  }
1109
1688
 
1110
1689
  interface SubagentOpts {
@@ -1115,6 +1694,7 @@ interface SubagentOpts {
1115
1694
  cwd: string;
1116
1695
  depth: number;
1117
1696
  maxDepth: number;
1697
+ budget?: SubagentBudget;
1118
1698
  // Idle-timeout is OFF by default: an RPC-mode pi is silent while it works,
1119
1699
  // so idle detection would false-kill a busy subagent. The absolute timeout
1120
1700
  // is the safety valve instead.
@@ -1132,12 +1712,29 @@ async function spawnSubagent(
1132
1712
  const model = opts.model ?? opts.agent?.model;
1133
1713
  if (model) piArgs.push("--model", model);
1134
1714
  const tools = opts.tools ?? opts.agent?.tools;
1715
+ if (messageNeedsReadTool(opts.task) && tools?.length && !tools.includes("read")) {
1716
+ return {
1717
+ error:
1718
+ "This task exceeds the PTY-safe inline limit and must be delivered through a file, " +
1719
+ "but the subagent tool allowlist excludes `read`. Add `read` or shorten the task.",
1720
+ };
1721
+ }
1135
1722
  if (tools && tools.length > 0) piArgs.push("--tools", tools.join(","));
1136
1723
  piArgs.push("--append-system-prompt", SUBAGENT_GUIDANCE);
1724
+ let promptTempFile: string | undefined;
1137
1725
  if (opts.agent?.systemPrompt?.trim()) {
1138
- const f = writePromptTempFile(opts.agent.name, opts.agent.systemPrompt);
1139
- piArgs.push("--append-system-prompt", f);
1726
+ promptTempFile = writePromptTempFile(opts.agent.name, opts.agent.systemPrompt);
1727
+ piArgs.push("--append-system-prompt", promptTempFile);
1140
1728
  }
1729
+ const cleanupPromptTemp = () => {
1730
+ if (!promptTempFile) return;
1731
+ try {
1732
+ fs.rmSync(path.dirname(promptTempFile), { recursive: true, force: true });
1733
+ } catch {
1734
+ /* best-effort */
1735
+ }
1736
+ promptTempFile = undefined;
1737
+ };
1141
1738
  // Self-reaper: a finished subagent exits after a short idle grace instead of
1142
1739
  // lingering until the absolute --timeout. Cancelled by a follow-up task, and
1143
1740
  // it never reaps a turn parked on a process-exit notification. See self-reap.ts.
@@ -1155,8 +1752,8 @@ async function spawnSubagent(
1155
1752
  opts.timeout,
1156
1753
  ];
1157
1754
  // Pretty-print the compact JSONL stream for humans who `attach`. The RPC
1158
- // proxy removes only cumulative streaming snapshots; authoritative final,
1159
- // tool, lifecycle, response, and error events remain intact for parseEvents.
1755
+ // proxy retains authoritative message_end/response/error events and reduces
1756
+ // redundant lifecycle/tool payloads; parseEvents accepts both log formats.
1160
1757
  if (VIEW_CMD.trim()) bsArgs.push("--view-cmd", VIEW_CMD);
1161
1758
  if (opts.idleTimeout && opts.idleTimeout !== "none") {
1162
1759
  bsArgs.push("--idle-timeout", opts.idleTimeout);
@@ -1178,12 +1775,14 @@ async function spawnSubagent(
1178
1775
  },
1179
1776
  });
1180
1777
  if (r.code !== 0) {
1778
+ cleanupPromptTemp();
1181
1779
  return { error: r.stderr || r.stdout || `babysit run failed (exit ${r.code}, no output) — check that \`${BABYSIT_BIN}\` works and ${ROOT} is writable` };
1182
1780
  }
1183
1781
  let id: string;
1184
1782
  try {
1185
1783
  id = JSON.parse(r.stdout).id;
1186
1784
  } catch {
1785
+ cleanupPromptTemp();
1187
1786
  return { error: `could not parse id from: ${r.stdout}` };
1188
1787
  }
1189
1788
 
@@ -1197,15 +1796,29 @@ async function spawnSubagent(
1197
1796
  notified: true,
1198
1797
  depth: opts.depth,
1199
1798
  maxDepth: opts.maxDepth,
1799
+ budget: opts.budget,
1200
1800
  });
1201
1801
 
1202
1802
  // Wait for pi to boot (first JSON event in the log), then inject the task.
1203
- await bs(["expect", "-s", id, "--timeout", "30s", '\\{"type"']);
1803
+ // Pi has loaded --append-system-prompt before emitting that event, so the
1804
+ // anonymous prompt file can be removed immediately instead of leaking.
1805
+ const boot = await bs(["expect", "-s", id, "--timeout", "30s", '\\{"type"']);
1806
+ cleanupPromptTemp();
1807
+ if (boot.code !== 0) {
1808
+ await bs(["kill", "-s", id]);
1809
+ return {
1810
+ error: boot.code === 124
1811
+ ? `subagent ${id} did not emit an RPC startup event within 30s`
1812
+ : boot.stderr || `subagent ${id} startup probe failed (code ${boot.code})`,
1813
+ };
1814
+ }
1815
+ const delivery = deliverableMessage("task", opts.task);
1204
1816
  const sent = await sendRpc(id, {
1205
1817
  type: "prompt",
1206
- message: `Task: ${deliverableMessage("task", opts.task)}`,
1818
+ message: `Task: ${delivery.message}`,
1207
1819
  });
1208
1820
  if ("error" in sent) {
1821
+ discardDelivery(delivery);
1209
1822
  await bs(["kill", "-s", id]);
1210
1823
  return { error: `could not send task to subagent ${id}: ${sent.error}` };
1211
1824
  }
@@ -1213,6 +1826,7 @@ async function spawnSubagent(
1213
1826
  // and similar config errors surface — fail the spawn loudly).
1214
1827
  const resp = await rpcResponse(id, sent.offset, "prompt", "60s");
1215
1828
  if (!resp.ok) {
1829
+ discardDelivery(delivery);
1216
1830
  await bs(["kill", "-s", id]);
1217
1831
  return { error: `subagent ${id} rejected the task: ${resp.error}` };
1218
1832
  }
@@ -1225,6 +1839,7 @@ async function spawnSubagent(
1225
1839
  if (st.ok && st.data) {
1226
1840
  const m = (st.data as { model?: { id?: string } | null }).model;
1227
1841
  if (m === null) {
1842
+ discardDelivery(delivery);
1228
1843
  await bs(["kill", "-s", id]);
1229
1844
  return {
1230
1845
  error: `subagent ${id} has no usable model${opts.model ? ` (requested "${opts.model}")` : ""} — check the model name with \`pi --list-models\`.`,
@@ -1237,10 +1852,15 @@ async function spawnSubagent(
1237
1852
  writeMeta(id, {
1238
1853
  kind: "subagent",
1239
1854
  task: opts.task,
1240
- promptOffset: sent.offset,
1855
+ promptOffset: resp.offset,
1241
1856
  model: resolvedModel,
1857
+ tools,
1858
+ messageTempDirs: delivery.tempDir
1859
+ ? [{ dir: delivery.tempDir, afterAgentEnd: 1 }]
1860
+ : undefined,
1242
1861
  depth: opts.depth,
1243
1862
  maxDepth: opts.maxDepth,
1863
+ budget: opts.budget,
1244
1864
  startedAt: Date.now(),
1245
1865
  });
1246
1866
  return { id, model: resolvedModel };
@@ -1282,43 +1902,31 @@ function sanitizeTailLine(s: string): string {
1282
1902
  // Trailing lines to show for a running session (sanitized, unprefixed).
1283
1903
  // process → raw log tail; subagent → derived activity (recent tool calls /
1284
1904
  // partial answer), since its log is RPC JSONL, not human-readable.
1285
- async function widgetTail(id: string, isSub: boolean): Promise<string[]> {
1905
+ async function widgetTail(
1906
+ id: string,
1907
+ isSub: boolean,
1908
+ subagentProgress?: Progress,
1909
+ ): Promise<string[]> {
1286
1910
  let raw: string[];
1287
1911
  if (!isSub) {
1288
1912
  const out = (await bs(["log", "-s", id, "--tail", String(WIDGET_TAIL_LINES)])).stdout;
1289
1913
  raw = out.split("\n");
1290
1914
  } 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);
1915
+ const progress = subagentProgress ?? taskProgressOf(id).progress;
1916
+ if (progress.finalText.trim()) {
1917
+ raw = progress.finalText.trim().split("\n");
1918
+ } else if (progress.toolCalls.length > 0) {
1919
+ raw = progress.toolCalls.map((tool) => tool.summary);
1299
1920
  } else {
1300
- raw = prog.errorMsg ? [`⚠ ${prog.errorMsg}`] : [];
1921
+ raw = progress.errorMsg ? [`⚠ ${progress.errorMsg}`] : [];
1301
1922
  }
1302
1923
  }
1303
1924
  return raw
1304
1925
  .map(sanitizeTailLine)
1305
- .filter((l) => l.trim().length > 0)
1926
+ .filter((line) => line.trim().length > 0)
1306
1927
  .slice(-WIDGET_TAIL_LINES);
1307
1928
  }
1308
1929
 
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
1930
  // ---------------------------------------------------------------------------
1323
1931
  // human take-over hint (no tmux dependency)
1324
1932
  // ---------------------------------------------------------------------------
@@ -1343,72 +1951,61 @@ interface WaitOutcome {
1343
1951
  progress?: Progress;
1344
1952
  }
1345
1953
 
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).
1954
+ // Wait for ONE subagent's current task. Completion = agent_settled without a
1955
+ // parked babysit_run/process result (a parked run only means "waiting for
1956
+ // process exit; pi will resume itself"). Parse appended bytes incrementally,
1957
+ // then block on the next agent_settled via race-free byte offsets.
1351
1958
  async function waitForTask(
1352
1959
  id: string,
1353
1960
  limitMs: number | null,
1354
1961
  signal?: AbortSignal,
1355
1962
  ): Promise<WaitOutcome> {
1356
- const meta = readMeta(id);
1357
- const base = meta?.promptOffset ?? 0;
1358
1963
  const t0 = Date.now();
1359
1964
 
1360
1965
  for (;;) {
1361
- const lg = await bs(["log", "-s", id, "--since", String(base), "--json"]);
1362
- let text = "";
1363
- let cur = base;
1966
+ let observed: { progress: Progress; offset: number };
1364
1967
  try {
1365
- const j = JSON.parse(lg.stdout);
1366
- text = j.text ?? "";
1367
- cur = j.offset ?? base;
1968
+ observed = taskProgressOf(id);
1368
1969
  } catch {
1369
- text = lg.stdout;
1970
+ observed = { progress: emptyProgress(), offset: readMeta(id)?.promptOffset ?? 0 };
1370
1971
  }
1371
- const prog = parseEvents(text);
1972
+ const { progress: prog, offset: cur } = observed;
1372
1973
  const st = await statusOf(id);
1373
1974
 
1374
1975
  const stats =
1375
- `turns=${prog.turns} tools=${prog.toolCalls.length}` +
1976
+ `turns=${prog.turns} calls=${prog.modelCalls} tools=${prog.toolCalls.length}` +
1376
1977
  (prog.tokens != null ? ` ctx=${prog.tokens}` : "") +
1377
- (prog.cost != null ? ` $${prog.cost.toFixed(4)}` : "");
1978
+ (prog.modelCalls > 0
1979
+ ? ` usage=${prog.usageTokens} (in=${prog.inputTokens} out=${prog.outputTokens} cache=${prog.cacheReadTokens}) $${prog.cost.toFixed(4)}`
1980
+ : "");
1378
1981
 
1379
1982
  if (prog.done) {
1380
- const body = clip(
1381
- prog.finalText.trim() || prog.errorMsg || "(no answer text)",
1382
- ANSWER_MAX_BYTES,
1383
- );
1384
- const ok = prog.finalText.trim().length > 0 || !prog.errorMsg;
1983
+ const completed = buildSubagentDoneResult(prog);
1385
1984
  return {
1386
1985
  id,
1387
1986
  kind: "done",
1388
- ok,
1987
+ ok: completed.ok,
1389
1988
  text:
1390
1989
  `Subagent ${id} finished its task (${stats}).\n` +
1391
1990
  `Session stays alive — follow-up: babysit_send { id: "${id}" }, ` +
1392
- `or babysit_kill when done.\n\n${body}`,
1991
+ `or babysit_kill when done.\n\n${completed.body}`,
1393
1992
  status: st,
1394
1993
  progress: prog,
1395
1994
  };
1396
1995
  }
1397
1996
 
1398
1997
  if (!st || st.state !== "running") {
1399
- // Crash / timeout / external kill — make the cause visible.
1400
- const tail = clip((await bs(["log", "-s", id, "--tail", "20"])).stdout.trim());
1401
- const body = clip(
1402
- prog.finalText.trim() || prog.errorMsg || tail || "(no output)",
1403
- ANSWER_MAX_BYTES,
1404
- );
1998
+ // Never inject a raw RPC JSON tail into model context. Structured errors
1999
+ // are parsed above; the complete stream remains available at the log path.
2000
+ const diagnostic = buildSubagentExitDiagnostic(prog, logPath(id));
1405
2001
  return {
1406
2002
  id,
1407
2003
  kind: "exited",
1408
2004
  ok: false,
1409
2005
  text:
1410
2006
  `Subagent ${id} EXITED before completing the task ` +
1411
- `(state=${st?.state ?? "missing"}, exit_code=${st?.exit_code ?? "?"}, ${stats}).\n\n${body}`,
2007
+ `(state=${st?.state ?? "missing"}, exit_code=${st?.exit_code ?? "?"}, ${stats}).\n\n` +
2008
+ diagnostic,
1412
2009
  status: st,
1413
2010
  progress: prog,
1414
2011
  };
@@ -1425,7 +2022,8 @@ async function waitForTask(
1425
2022
  progress: prog,
1426
2023
  });
1427
2024
 
1428
- // Still working — block on the next agent_end.
2025
+ // Still working — block until Pi declares the run fully settled. Unlike
2026
+ // agent_end, this cannot fire before an automatic retry/compaction retry.
1429
2027
  let expectTimeout = "0"; // indefinite
1430
2028
  if (limitMs != null) {
1431
2029
  const remaining = limitMs - (Date.now() - t0);
@@ -1433,7 +2031,7 @@ async function waitForTask(
1433
2031
  expectTimeout = `${Math.ceil(remaining / 1000)}s`;
1434
2032
  }
1435
2033
  const e = await bs(
1436
- ["expect", "-s", id, "--since", String(cur), "--timeout", expectTimeout, '"type":"agent_end"'],
2034
+ ["expect", "-s", id, "--since", String(cur), "--timeout", expectTimeout, '"type":"agent_settled"'],
1437
2035
  { signal },
1438
2036
  );
1439
2037
  if (signal?.aborted || e.code === 130) {
@@ -1586,12 +2184,20 @@ async function waitForExit(
1586
2184
  }
1587
2185
  }
1588
2186
 
1589
- const st = await statusOf(id);
2187
+ const statusLookup = await lookupStatus(id);
2188
+ if (statusLookup.error) {
2189
+ // The registry could not be read, so do not convert a transient backend
2190
+ // failure into a missing session or permanently claim its notification.
2191
+ if (!expectPattern) updateWaitReservation(id, "abandon");
2192
+ return {
2193
+ id,
2194
+ kind: "interrupted",
2195
+ ok: false,
2196
+ text: `Could not verify ${id} after waiting: ${statusLookup.error}`,
2197
+ };
2198
+ }
2199
+ const st = statusLookup.session;
1590
2200
  if (!st) {
1591
- // A non-pattern backend wait returned before this lookup, so preserve its
1592
- // completion claim when status persistence is transiently unavailable.
1593
- // Abandoning here can re-enable the poller and duplicate a completion the
1594
- // explicit wait already consumed. This matches the old suppress-first rule.
1595
2201
  if (!expectPattern) updateWaitReservation(id, "claim");
1596
2202
  return { id, kind: "exited", ok: false, text: `No such session: ${id}` };
1597
2203
  }
@@ -1658,14 +2264,35 @@ export function activeToolsWithoutDirectBash(activeTools: string[], allowDirectB
1658
2264
 
1659
2265
  export default function (pi: ExtensionAPI) {
1660
2266
  let pollTimer: ReturnType<typeof setInterval> | undefined;
2267
+ let pollNeeded = true;
2268
+ let automaticGcRan = false;
2269
+ let rootLeasePath: string | undefined;
1661
2270
  const declaredToolErrors = new Set<string>();
2271
+ const sessionRpcTails = new Map<string, Promise<void>>();
2272
+
2273
+ async function withSessionRpcLock<T>(id: string, operation: () => Promise<T>): Promise<T> {
2274
+ const previous = sessionRpcTails.get(id) ?? Promise.resolve();
2275
+ let release!: () => void;
2276
+ const gate = new Promise<void>((resolve) => {
2277
+ release = resolve;
2278
+ });
2279
+ const tail = previous.then(() => gate);
2280
+ sessionRpcTails.set(id, tail);
2281
+ await previous;
2282
+ try {
2283
+ return await operation();
2284
+ } finally {
2285
+ release();
2286
+ if (sessionRpcTails.get(id) === tail) sessionRpcTails.delete(id);
2287
+ }
2288
+ }
1662
2289
 
1663
2290
  // Pi only persists custom-tool failures when they are thrown or patched by a
1664
2291
  // tool_result hook; an `isError` property returned from execute() is ignored.
1665
2292
  // Keep the structured result (status, log path, diagnostics) while promoting
1666
2293
  // its declared error bit at the supported hook boundary.
1667
- const registerTool = <TParams extends TSchema, TDetails, TState>(
1668
- tool: ToolDefinition<TParams, TDetails, TState>,
2294
+ const registerTool = <TParams extends TSchema>(
2295
+ tool: ToolDefinition<TParams, unknown, unknown>,
1669
2296
  ): void => {
1670
2297
  const execute = tool.execute;
1671
2298
  pi.registerTool({
@@ -1684,23 +2311,104 @@ export default function (pi: ExtensionAPI) {
1684
2311
  if (declaredToolErrors.delete(event.toolCallId)) return { isError: true };
1685
2312
  });
1686
2313
 
2314
+ async function enforceSubagentBudgets(sessions: BsSession[]): Promise<void> {
2315
+ for (const session of sessions) {
2316
+ if (session.state !== "running") continue;
2317
+ await withSessionRpcLock(session.id, async () => {
2318
+ const latestStatus = await statusOf(session.id);
2319
+ const meta = readMeta(session.id);
2320
+ if (
2321
+ latestStatus?.state !== "running" ||
2322
+ meta?.kind !== "subagent" ||
2323
+ !meta.budget ||
2324
+ meta.budgetKilled
2325
+ ) {
2326
+ return;
2327
+ }
2328
+
2329
+ let progress: Progress;
2330
+ try {
2331
+ progress = taskProgressOf(session.id).progress;
2332
+ } catch {
2333
+ return;
2334
+ }
2335
+ if (progress.done) return;
2336
+ const now = Date.now();
2337
+ const decision = subagentBudgetAction(
2338
+ progress,
2339
+ meta.budget,
2340
+ meta.budgetExceededAt,
2341
+ now,
2342
+ SUBAGENT_BUDGET_GRACE_MS,
2343
+ );
2344
+ if (decision.action === "none") return;
2345
+ const reason = decision.reason as string;
2346
+
2347
+ if (decision.action === "steer") {
2348
+ // Start the grace period only after Pi accepts the steering command.
2349
+ const sent = await sendRpc(session.id, {
2350
+ type: "steer",
2351
+ message: `Budget reached (${reason}). Stop calling tools and provide your final answer now.`,
2352
+ });
2353
+ if ("error" in sent) return;
2354
+ const accepted = await rpcResponse(session.id, sent.offset, "steer", "3s");
2355
+ if (!accepted.ok) return;
2356
+ const current = readMeta(session.id);
2357
+ if (
2358
+ current?.kind !== "subagent" ||
2359
+ current.promptOffset !== meta.promptOffset ||
2360
+ current.budgetExceededAt
2361
+ ) {
2362
+ return;
2363
+ }
2364
+ current.budgetExceededAt = now;
2365
+ current.budgetReason = reason;
2366
+ writeMeta(session.id, current);
2367
+ return;
2368
+ }
2369
+
2370
+ const killed = await bs(["kill", "-s", session.id, "--json"]);
2371
+ if (killed.code !== 0) return;
2372
+ const terminal = await awaitConfirmedTermination(session.id);
2373
+ const current = readMeta(session.id);
2374
+ if (
2375
+ terminal &&
2376
+ isConfirmedTerminalState(terminal.state) &&
2377
+ current?.kind === "subagent" &&
2378
+ current.promptOffset === meta.promptOffset &&
2379
+ current.budgetExceededAt === meta.budgetExceededAt
2380
+ ) {
2381
+ current.budgetKilled = true;
2382
+ current.budgetReason = current.budgetReason ?? reason;
2383
+ writeMeta(session.id, current);
2384
+ }
2385
+ });
2386
+ }
2387
+ }
2388
+
1687
2389
  // Exit notifications for kind=process sessions: the poller detects
1688
2390
  // running→exited transitions and injects ONE message (triggerTurn) for all
1689
2391
  // processes that became deliverable in the same poll. This resumes an agent
1690
2392
  // that ended its turn after babysit_run without spending one turn per exit.
1691
2393
  // Kills via babysit_kill and exits already reported by babysit_wait are
1692
2394
  // suppressed via meta.notified.
1693
- async function notifyEndedProcesses(ctx: ExtensionContext): Promise<void> {
2395
+ async function notifyEndedProcesses(
2396
+ ctx: ExtensionContext,
2397
+ snapshot?: BsSession[],
2398
+ ): Promise<void> {
1694
2399
  // Never steer a completion into an active agent turn. In particular, this
1695
2400
  // lets an immediately-following babysit_wait reserve the completion first,
1696
2401
  // instead of racing the poller and receiving both wait + auto notification.
1697
2402
  if (shouldDeferCompletionNotification(ctx.isIdle())) return;
1698
- const { sessions } = await listSessions();
2403
+ const sessions = snapshot ?? (await listSessions()).sessions;
1699
2404
  const ready: Array<{ session: BsSession; meta: Meta }> = [];
1700
2405
  for (const session of sessions) {
1701
2406
  if (session.state === "running") continue;
1702
2407
  const meta = readMeta(session.id);
1703
2408
  if (!shouldDeliverProcessCompletion(meta)) continue;
2409
+ // A notification group is delivered only after all currently known
2410
+ // members have stopped, even when their exit times span many polls.
2411
+ if (!isNotificationGroupReady(meta, sessions, readMeta)) continue;
1704
2412
  // Delay delivery by one poll interval. This gives an agent that chose
1705
2413
  // babysit_wait immediately after babysit_run enough time to claim the
1706
2414
  // completion and suppress the otherwise duplicate automatic message.
@@ -1743,13 +2451,17 @@ export default function (pi: ExtensionAPI) {
1743
2451
  });
1744
2452
  }
1745
2453
 
1746
- // Output loading above is asynchronous. Re-read every candidate together
1747
- // immediately before the single send so a concurrent kill/wait cannot claim
1748
- // an early candidate while a later candidate's output is being loaded.
2454
+ // Output loading above is asynchronous. Refresh sessions and metadata
2455
+ // immediately before the single send so concurrent wait/kill calls and newly
2456
+ // started notification-group members are honored.
2457
+ const refreshed = await listSessions();
2458
+ if (refreshed.error) return;
2459
+ const finalSessions = refreshed.sessions;
1749
2460
  const metadataById = new Map<string, Meta>();
1750
2461
  const notices = prepared.flatMap((notice) => {
1751
2462
  const current = readMeta(notice.id);
1752
2463
  if (!shouldDeliverProcessCompletion(current)) return [];
2464
+ if (!isNotificationGroupReady(current, finalSessions, readMeta)) return [];
1753
2465
  metadataById.set(notice.id, current);
1754
2466
  return [{ ...notice, command: current.command }];
1755
2467
  });
@@ -1769,30 +2481,45 @@ export default function (pi: ExtensionAPI) {
1769
2481
  );
1770
2482
  }
1771
2483
 
1772
- const refreshWidget = async (ctx: ExtensionContext) => {
2484
+ const refreshWidget = async (ctx: ExtensionContext, snapshot?: BsSession[]) => {
1773
2485
  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");
2486
+ const active = (snapshot ?? (await listSessions()).sessions).filter(
2487
+ (session) => session.state === "running",
2488
+ );
2489
+ const subs = active.filter((session) => kindOf(session.id) === "subagent");
1776
2490
  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;
2491
+ const progressById = new Map<string, Progress>();
2492
+ for (const subagent of subs) {
2493
+ try {
2494
+ progressById.set(subagent.id, taskProgressOf(subagent.id).progress);
2495
+ } catch {
2496
+ progressById.set(subagent.id, emptyProgress());
2497
+ }
2498
+ }
2499
+ const idle = subs.filter((session) => progressById.get(session.id)?.done).length;
1779
2500
  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
2501
  const tails = await Promise.all(
1783
- active.map((s) => widgetTail(s.id, kindOf(s.id) === "subagent")),
2502
+ active.map((session) => {
2503
+ const isSubagent = kindOf(session.id) === "subagent";
2504
+ return widgetTail(
2505
+ session.id,
2506
+ isSubagent,
2507
+ isSubagent ? progressById.get(session.id) : undefined,
2508
+ );
2509
+ }),
1784
2510
  );
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]}`);
2511
+ active.forEach((session, index) => {
2512
+ const isSubagent = kindOf(session.id) === "subagent";
2513
+ const tag = isSubagent
2514
+ ? progressById.get(session.id)?.done ? "sub idle" : "sub"
2515
+ : "proc";
2516
+ const elapsed = elapsedOf(session.id);
2517
+ const header = ` ⏳ ${session.id} [${tag}]${elapsed ? ` ${elapsed}` : ""}`;
2518
+ if (tails[index].length === 1) {
2519
+ lines.push(`${header} │ ${tails[index][0]}`);
1793
2520
  } else {
1794
2521
  lines.push(header);
1795
- for (const t of tails[i]) lines.push(` │ ${t}`);
2522
+ for (const tail of tails[index]) lines.push(` │ ${tail}`);
1796
2523
  }
1797
2524
  });
1798
2525
  ctx.ui.setWidget("pi-babysit", lines, { placement: "belowEditor" });
@@ -1882,25 +2609,71 @@ export default function (pi: ExtensionAPI) {
1882
2609
  // Session-local registry: scope the babysit root to this pi session so
1883
2610
  // other sessions' processes/subagents are invisible here. Resuming a
1884
2611
  // session keeps the same id, so its sessions come back with it.
2612
+ releaseRootLease(rootLeasePath);
1885
2613
  try {
1886
2614
  ROOT = path.join(ROOT_BASE, ctx.sessionManager.getSessionId());
1887
2615
  } catch {
1888
2616
  ROOT = ROOT_BASE;
1889
2617
  }
2618
+ rootLeasePath = acquireRootLease(ROOT) ?? undefined;
2619
+ if (!rootLeasePath) {
2620
+ const desiredRoot = ROOT;
2621
+ ROOT = `${desiredRoot}-active-${process.pid}-${Date.now()}`;
2622
+ rootLeasePath = acquireRootLease(ROOT) ?? undefined;
2623
+ if (ctx.hasUI) {
2624
+ ctx.ui.notify(
2625
+ `Babysit GC was busy for ${desiredRoot}; using an isolated root for this run.`,
2626
+ "warning",
2627
+ );
2628
+ }
2629
+ }
2630
+ taskProgressCache.clear();
2631
+ pollNeeded = true;
2632
+ const retentionDays = Number(process.env.PI_BABYSIT_RETENTION_DAYS);
2633
+ if (!automaticGcRan && Number.isFinite(retentionDays) && retentionDays > 0) {
2634
+ automaticGcRan = true;
2635
+ const gc = gcBabysitRoots({
2636
+ rootBase: ROOT_BASE,
2637
+ currentRoot: ROOT,
2638
+ olderThanMs: retentionDays * 86_400_000,
2639
+ dryRun: false,
2640
+ });
2641
+ if (gc.deleted.length > 0 && ctx.hasUI) {
2642
+ ctx.ui.notify(
2643
+ `pi-babysit GC removed ${gc.deleted.length} roots (${gc.bytes} bytes).`,
2644
+ "info",
2645
+ );
2646
+ }
2647
+ }
1890
2648
  // Warn early if the binary is missing so the user isn't surprised only when
1891
2649
  // a tool later fails. Tools/commands still enforce it via requireBabysit.
1892
2650
  if (ctx.hasUI && !(await babysitAvailable())) {
1893
- ctx.ui.notify(babysitPreflightError ?? INSTALL_HINT, "warn");
2651
+ ctx.ui.notify(babysitPreflightError ?? INSTALL_HINT, "warning");
1894
2652
  }
1895
2653
  if (pollTimer) clearInterval(pollTimer);
1896
2654
  pollTimer = setInterval(() => {
1897
- // Skip if the previous (async) poll hasn't finished, so slow babysit
1898
- // calls never stack up.
1899
- if (polling) return;
2655
+ // Once a namespace has no live workers or pending notifications, avoid
2656
+ // spawning `babysit list` forever while the UI is idle. A successful
2657
+ // babysit_run re-arms polling below.
2658
+ if (polling || !pollNeeded) return;
1900
2659
  polling = true;
1901
- Promise.all([notifyEndedProcesses(ctx), refreshWidget(ctx)])
2660
+ void (async () => {
2661
+ const listed = await listSessions();
2662
+ if (listed.error) {
2663
+ pollNeeded = shouldKeepPollingAfterList(listed, readMeta);
2664
+ throw new Error(listed.error);
2665
+ }
2666
+ const snapshot = listed.sessions;
2667
+ await enforceSubagentBudgets(snapshot);
2668
+ await Promise.all([
2669
+ notifyEndedProcesses(ctx, snapshot),
2670
+ refreshWidget(ctx, snapshot),
2671
+ ]);
2672
+ pollNeeded = shouldKeepPolling(snapshot, readMeta);
2673
+ })()
1902
2674
  .catch(() => {
1903
- /* ignore poll errors */
2675
+ // Keep polling armed after a transient CLI/filesystem error.
2676
+ pollNeeded = true;
1904
2677
  })
1905
2678
  .finally(() => {
1906
2679
  polling = false;
@@ -1911,6 +2684,8 @@ export default function (pi: ExtensionAPI) {
1911
2684
  pi.on("session_shutdown", async () => {
1912
2685
  if (pollTimer) clearInterval(pollTimer);
1913
2686
  pollTimer = undefined;
2687
+ releaseRootLease(rootLeasePath);
2688
+ rootLeasePath = undefined;
1914
2689
  });
1915
2690
 
1916
2691
  pi.on("tool_call", async (event) => {
@@ -1998,6 +2773,24 @@ export default function (pi: ExtensionAPI) {
1998
2773
  "Maximum subagent nesting depth. Top-level subagent mode only; default 1 prevents workers from spawning workers. Nested workers inherit this limit and cannot override it.",
1999
2774
  }),
2000
2775
  ),
2776
+ maxCost: Type.Optional(
2777
+ Type.Number({
2778
+ exclusiveMinimum: 0,
2779
+ description: "Subagent only: steer it to wrap up at this cumulative reported cost, then kill after the budget grace period.",
2780
+ }),
2781
+ ),
2782
+ maxTurns: Type.Optional(
2783
+ Type.Integer({ minimum: 1, description: "Subagent only: maximum turns per task." }),
2784
+ ),
2785
+ maxToolCalls: Type.Optional(
2786
+ Type.Integer({ minimum: 1, description: "Subagent only: maximum tool calls per task." }),
2787
+ ),
2788
+ maxUsageTokens: Type.Optional(
2789
+ Type.Integer({
2790
+ minimum: 1,
2791
+ description: "Subagent only: maximum cumulative reported totalTokens across model calls.",
2792
+ }),
2793
+ ),
2001
2794
  agentScope: Type.Optional(
2002
2795
  StringEnum(["user", "project", "both"] as const, {
2003
2796
  description: "Where to discover named agents. Default 'user'.",
@@ -2021,6 +2814,12 @@ export default function (pi: ExtensionAPI) {
2021
2814
  "Process mode: run in a PTY (default true; enables interactive input/screen). false = plain pipes for cleaner line-oriented logs.",
2022
2815
  }),
2023
2816
  ),
2817
+ notificationGroup: Type.Optional(
2818
+ Type.String({
2819
+ description:
2820
+ "Process mode: defer automatic completion until every running process with this group has stopped, then send one batched notification.",
2821
+ }),
2822
+ ),
2024
2823
  continueAfterStart: Type.Optional(
2025
2824
  Type.Boolean({
2026
2825
  description:
@@ -2059,6 +2858,25 @@ export default function (pi: ExtensionAPI) {
2059
2858
  details: {},
2060
2859
  };
2061
2860
  }
2861
+ const hasBudget =
2862
+ params.maxCost != null ||
2863
+ params.maxTurns != null ||
2864
+ params.maxToolCalls != null ||
2865
+ params.maxUsageTokens != null;
2866
+ if (!isSubagent && hasBudget) {
2867
+ return {
2868
+ content: [{ type: "text", text: "Subagent budget parameters require profile 'subagent'." }],
2869
+ isError: true,
2870
+ details: {},
2871
+ };
2872
+ }
2873
+ if (isSubagent && params.notificationGroup) {
2874
+ return {
2875
+ content: [{ type: "text", text: "`notificationGroup` is available only in process mode." }],
2876
+ isError: true,
2877
+ details: {},
2878
+ };
2879
+ }
2062
2880
 
2063
2881
  // Compute nesting only for subagent mode. Ordinary command processes remain
2064
2882
  // available even when the hosting agent is at its subagent depth limit.
@@ -2082,6 +2900,7 @@ export default function (pi: ExtensionAPI) {
2082
2900
  timeout: params.timeout,
2083
2901
  idleTimeout: params.idleTimeout,
2084
2902
  pty: params.pty ?? true,
2903
+ notificationGroup: params.notificationGroup?.trim() || undefined,
2085
2904
  };
2086
2905
  let res = await spawnProcess(spawnOpts);
2087
2906
  if ("error" in res) {
@@ -2091,6 +2910,7 @@ export default function (pi: ExtensionAPI) {
2091
2910
  details: {},
2092
2911
  };
2093
2912
  }
2913
+ pollNeeded = true;
2094
2914
  await refreshWidget(ctx);
2095
2915
 
2096
2916
  // One-shot / non-interactive mode (e.g. `pi -p`): there is no live
@@ -2223,6 +3043,15 @@ export default function (pi: ExtensionAPI) {
2223
3043
  cwd: ctx.cwd,
2224
3044
  depth: subagentNesting.childDepth,
2225
3045
  maxDepth: subagentNesting.maxDepth,
3046
+ budget:
3047
+ hasBudget
3048
+ ? {
3049
+ maxCost: params.maxCost,
3050
+ maxTurns: params.maxTurns,
3051
+ maxToolCalls: params.maxToolCalls,
3052
+ maxUsageTokens: params.maxUsageTokens,
3053
+ }
3054
+ : undefined,
2226
3055
  timeout: params.timeout ?? "15m",
2227
3056
  idleTimeout: params.idleTimeout,
2228
3057
  });
@@ -2235,6 +3064,7 @@ export default function (pi: ExtensionAPI) {
2235
3064
  };
2236
3065
  }
2237
3066
 
3067
+ pollNeeded = true;
2238
3068
  await refreshWidget(ctx);
2239
3069
  return {
2240
3070
  content: [
@@ -2439,10 +3269,8 @@ export default function (pi: ExtensionAPI) {
2439
3269
  };
2440
3270
  }
2441
3271
 
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);
3272
+ // --- subagent: analyze only bytes appended for the current task ---
3273
+ const prog = taskProgressOf(params.id).progress;
2446
3274
  const nTools = Math.min(Math.max(1, params.tools ?? 8), 50);
2447
3275
  const recent = prog.toolCalls.slice(-nTools);
2448
3276
 
@@ -2459,9 +3287,9 @@ export default function (pi: ExtensionAPI) {
2459
3287
  : " · working";
2460
3288
  }
2461
3289
  if (st.exit_code != null) header += ` exit_code=${st.exit_code}`;
2462
- header += ` turns=${prog.turns} tools=${prog.toolCalls.length}`;
3290
+ header += ` turns=${prog.turns} calls=${prog.modelCalls} tools=${prog.toolCalls.length}`;
2463
3291
  if (prog.tokens != null) header += ` ctx=${prog.tokens}`;
2464
- if (prog.cost != null) header += ` $${prog.cost.toFixed(4)}`;
3292
+ if (prog.modelCalls > 0) header += ` usage=${prog.usageTokens} $${prog.cost.toFixed(4)}`;
2465
3293
  if (st.note) header += ` ⚑ ${st.note}`;
2466
3294
  parts.push(header);
2467
3295
 
@@ -2478,10 +3306,7 @@ export default function (pi: ExtensionAPI) {
2478
3306
  if (prog.finalText.trim()) {
2479
3307
  parts.push(`--- answer so far ---\n${clip(prog.finalText.trim(), ANSWER_MAX_BYTES)}`);
2480
3308
  } else if (prog.toolCalls.length === 0 && st.state !== "running") {
2481
- // Died before doing anything — show the log tail so the cause of
2482
- // death (model error, crash, timeout) is visible, not hidden.
2483
- const tail = clip((await bs(["log", "-s", params.id, "--tail", "15"])).stdout.trim());
2484
- parts.push(tail ? `--- last output ---\n${tail}` : "(no output)");
3309
+ parts.push(buildSubagentExitDiagnostic(prog, logPath(params.id)));
2485
3310
  } else if (prog.toolCalls.length === 0) {
2486
3311
  parts.push("(starting up… no events yet)");
2487
3312
  } else {
@@ -2489,7 +3314,7 @@ export default function (pi: ExtensionAPI) {
2489
3314
  }
2490
3315
 
2491
3316
  return {
2492
- content: [{ type: "text", text: parts.join("\n") }],
3317
+ content: [{ type: "text", text: clip(parts.join("\n")) }],
2493
3318
  details: { status: st, progress: prog, kind: "subagent" },
2494
3319
  };
2495
3320
  },
@@ -2584,6 +3409,21 @@ export default function (pi: ExtensionAPI) {
2584
3409
  }
2585
3410
 
2586
3411
  // --- subagent: steer / follow-up task over RPC ---
3412
+ return withSessionRpcLock(params.id, async () => {
3413
+ const lockedStatus = await statusOf(params.id);
3414
+ const meta = readMeta(params.id);
3415
+ if (lockedStatus?.state !== "running" || meta?.kind !== "subagent") {
3416
+ return {
3417
+ content: [
3418
+ {
3419
+ type: "text" as const,
3420
+ text: `Session ${params.id} is not a running subagent (${lockedStatus?.state ?? "missing"}).`,
3421
+ },
3422
+ ],
3423
+ isError: true,
3424
+ details: {},
3425
+ };
3426
+ }
2587
3427
  if (!params.text) {
2588
3428
  return {
2589
3429
  content: [{ type: "text", text: "Provide `text` (steering or follow-up task)." }],
@@ -2591,6 +3431,18 @@ export default function (pi: ExtensionAPI) {
2591
3431
  details: {},
2592
3432
  };
2593
3433
  }
3434
+ if (messageNeedsReadTool(params.text) && meta.tools?.length && !meta.tools.includes("read")) {
3435
+ return {
3436
+ content: [
3437
+ {
3438
+ type: "text",
3439
+ 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.",
3440
+ },
3441
+ ],
3442
+ isError: true,
3443
+ details: {},
3444
+ };
3445
+ }
2594
3446
  let mode = params.mode ?? "auto";
2595
3447
  if (mode === "auto") {
2596
3448
  // isStreaming tells us whether an agent run is in flight right now.
@@ -2602,12 +3454,27 @@ export default function (pi: ExtensionAPI) {
2602
3454
  }
2603
3455
  mode = streaming ? "steer" : "task";
2604
3456
  }
3457
+ const deliveryCleanupAfter =
3458
+ mode === "steer"
3459
+ ? (() => {
3460
+ try {
3461
+ return taskProgressOf(params.id).progress.agentEnds + 1;
3462
+ } catch {
3463
+ return 1;
3464
+ }
3465
+ })()
3466
+ : 1;
3467
+ const delivery = deliverableMessage(
3468
+ mode === "steer" ? "steering message" : "task",
3469
+ params.text,
3470
+ );
2605
3471
  const cmd =
2606
3472
  mode === "steer"
2607
- ? { type: "steer", message: deliverableMessage("steering message", params.text) }
2608
- : { type: "prompt", message: deliverableMessage("task", params.text) };
3473
+ ? { type: "steer", message: delivery.message }
3474
+ : { type: "prompt", message: delivery.message };
2609
3475
  const sent = await sendRpc(params.id, cmd);
2610
3476
  if ("error" in sent) {
3477
+ discardDelivery(delivery);
2611
3478
  return {
2612
3479
  content: [{ type: "text", text: sent.error }],
2613
3480
  isError: true,
@@ -2616,6 +3483,7 @@ export default function (pi: ExtensionAPI) {
2616
3483
  }
2617
3484
  const resp = await rpcResponse(params.id, sent.offset, cmd.type, "15s");
2618
3485
  if (!resp.ok) {
3486
+ discardDelivery(delivery);
2619
3487
  return {
2620
3488
  content: [{ type: "text", text: `${cmd.type} was not accepted: ${resp.error}` }],
2621
3489
  isError: true,
@@ -2627,10 +3495,29 @@ export default function (pi: ExtensionAPI) {
2627
3495
  writeMeta(params.id, {
2628
3496
  kind: "subagent",
2629
3497
  task: params.text,
2630
- promptOffset: sent.offset,
3498
+ // Start after the prompt-acceptance response. This excludes a
3499
+ // preceding run that settled between `send` and RPC acceptance.
3500
+ promptOffset: resp.offset,
2631
3501
  model: meta?.model,
3502
+ tools: meta?.tools,
3503
+ messageTempDirs: delivery.tempDir
3504
+ ? [{ dir: delivery.tempDir, afterAgentEnd: deliveryCleanupAfter }]
3505
+ : undefined,
2632
3506
  depth: meta?.depth,
2633
3507
  maxDepth: meta?.maxDepth,
3508
+ budget: meta?.budget,
3509
+ // Each follow-up task receives a fresh budget window.
3510
+ budgetExceededAt: undefined,
3511
+ budgetReason: undefined,
3512
+ budgetKilled: undefined,
3513
+ });
3514
+ } else if (delivery.tempDir && meta) {
3515
+ writeMeta(params.id, {
3516
+ ...meta,
3517
+ messageTempDirs: [
3518
+ ...(meta.messageTempDirs ?? []),
3519
+ { dir: delivery.tempDir, afterAgentEnd: deliveryCleanupAfter },
3520
+ ],
2634
3521
  });
2635
3522
  }
2636
3523
  return {
@@ -2645,6 +3532,7 @@ export default function (pi: ExtensionAPI) {
2645
3532
  ],
2646
3533
  details: { mode, kind: "subagent" },
2647
3534
  };
3535
+ });
2648
3536
  },
2649
3537
  });
2650
3538
 
@@ -2658,6 +3546,8 @@ export default function (pi: ExtensionAPI) {
2658
3546
  "for 'listening on' before hitting a dev server). A subagent finishes when its current " +
2659
3547
  "TASK completes (the session stays alive for follow-ups). Pass `id` for one session, or " +
2660
3548
  "`ids` + `mode`: 'all' (default) waits for every one, 'any' returns on the FIRST finisher. " +
3549
+ "Multi-session results are capped at the inline-output limit (8 KB by default); use `maxBytes` " +
3550
+ "to opt into a larger result up to 24 KB. " +
2661
3551
  "Prefer ending your turn over babysit_wait when a process result is not needed this turn — " +
2662
3552
  "the exit notification will resume you.",
2663
3553
  promptSnippet: "Block until session(s) finish — process exit / output pattern / subagent task done",
@@ -2665,7 +3555,8 @@ export default function (pi: ExtensionAPI) {
2665
3555
  id: Type.Optional(Type.String({ description: "Session id (single wait)." })),
2666
3556
  ids: Type.Optional(
2667
3557
  Type.Array(Type.String(), {
2668
- description: "Session ids for a multi-wait (use with mode).",
3558
+ maxItems: MAX_MULTI_WAIT_SESSIONS,
3559
+ description: `Session ids for a multi-wait (use with mode; maximum ${MAX_MULTI_WAIT_SESSIONS}, no duplicates).`,
2669
3560
  }),
2670
3561
  ),
2671
3562
  mode: Type.Optional(
@@ -2682,6 +3573,14 @@ export default function (pi: ExtensionAPI) {
2682
3573
  "Process only: return as soon as this regex appears in the output (readiness marker) instead of waiting for exit.",
2683
3574
  }),
2684
3575
  ),
3576
+ maxBytes: Type.Optional(
3577
+ Type.Integer({
3578
+ minimum: 1_000,
3579
+ maximum: ANSWER_MAX_BYTES,
3580
+ description:
3581
+ "Multi-session result cap in bytes. Defaults to PI_BABYSIT_INLINE_OUTPUT_MAX_BYTES (8 KB).",
3582
+ }),
3583
+ ),
2685
3584
  }),
2686
3585
  async execute(_id, params, signal) {
2687
3586
  await requireBabysit();
@@ -2693,7 +3592,27 @@ export default function (pi: ExtensionAPI) {
2693
3592
  details: {},
2694
3593
  };
2695
3594
  }
3595
+ if (ids.length > MAX_MULTI_WAIT_SESSIONS) {
3596
+ return {
3597
+ content: [{ type: "text", text: `Multi-wait supports at most ${MAX_MULTI_WAIT_SESSIONS} sessions.` }],
3598
+ isError: true,
3599
+ details: {},
3600
+ };
3601
+ }
3602
+ if (new Set(ids).size !== ids.length) {
3603
+ return {
3604
+ content: [{ type: "text", text: "Multi-wait session ids must be unique." }],
3605
+ isError: true,
3606
+ details: {},
3607
+ };
3608
+ }
2696
3609
  const limitMs = parseDurMs(params.timeout);
3610
+ const multiResultMaxBytes = params.maxBytes ?? INLINE_OUTPUT_MAX_BYTES;
3611
+ if (params.timeout !== undefined && limitMs === null) {
3612
+ throw new Error(
3613
+ `Invalid timeout ${JSON.stringify(params.timeout)}; use an integer with ms, s, m, or h (for example "90s" or "5m").`,
3614
+ );
3615
+ }
2697
3616
 
2698
3617
  if (ids.length === 1) {
2699
3618
  const r = await waitFor(ids[0], limitMs, signal, params.expect);
@@ -2719,7 +3638,10 @@ export default function (pi: ExtensionAPI) {
2719
3638
  content: [
2720
3639
  {
2721
3640
  type: "text",
2722
- text: results.map((r) => `── ${r.id} [${r.kind}] ──\n${r.text}`).join("\n\n"),
3641
+ text: clipMultiWaitResult(
3642
+ results.map((result) => `── ${result.id} [${result.kind}] ──\n${result.text}`).join("\n\n"),
3643
+ multiResultMaxBytes,
3644
+ ),
2723
3645
  },
2724
3646
  ],
2725
3647
  isError: !ok,
@@ -2743,10 +3665,12 @@ export default function (pi: ExtensionAPI) {
2743
3665
  content: [
2744
3666
  {
2745
3667
  type: "text",
2746
- text:
3668
+ text: clipMultiWaitResult(
2747
3669
  `First to finish: ${first.id} [${first.kind}]` +
2748
- (others.length ? ` — still waiting-able: ${others.join(", ")}` : "") +
2749
- `\n\n${first.text}`,
3670
+ (others.length ? ` — still waiting-able: ${others.join(", ")}` : "") +
3671
+ `\n\n${first.text}`,
3672
+ multiResultMaxBytes,
3673
+ ),
2750
3674
  },
2751
3675
  ],
2752
3676
  isError: !first.ok,
@@ -2817,8 +3741,46 @@ export default function (pi: ExtensionAPI) {
2817
3741
  // output + a copy-paste `babysit attach` take-over hint; running subagent →
2818
3742
  // read-only progress; finished → summary. Re-run to refresh.
2819
3743
  pi.registerCommand("babysit", {
2820
- description: "Pick a babysit session (↑/↓) to snapshot/inspect",
2821
- handler: async (_args, ctx) => {
3744
+ description: "Pick a session to inspect, or `/babysit gc [days]` to remove old terminal roots",
3745
+ handler: async (args, ctx) => {
3746
+ const command = args.trim();
3747
+ if (command === "gc" || command.startsWith("gc ")) {
3748
+ const daysText = command.slice(2).trim();
3749
+ const days = daysText ? Number(daysText) : 14;
3750
+ if (!Number.isFinite(days) || days <= 0) {
3751
+ ctx.ui.notify("Usage: /babysit gc [positive retention days]", "error");
3752
+ return;
3753
+ }
3754
+ const options = {
3755
+ rootBase: ROOT_BASE,
3756
+ currentRoot: ROOT,
3757
+ olderThanMs: days * 86_400_000,
3758
+ };
3759
+ const preview = gcBabysitRoots({ ...options, dryRun: true });
3760
+ if (preview.candidates.length === 0) {
3761
+ ctx.ui.notify(
3762
+ `No terminal pi-babysit roots older than ${days} days are safe to remove.`,
3763
+ "info",
3764
+ );
3765
+ return;
3766
+ }
3767
+ const confirmed = await ctx.ui.confirm(
3768
+ "Remove old pi-babysit roots?",
3769
+ `${preview.candidates.length} roots · ${preview.bytes} bytes · older than ${days} days\n` +
3770
+ "Running supervisors and the current Pi session are excluded.",
3771
+ );
3772
+ if (!confirmed) return;
3773
+ const removed = gcBabysitRoots({ ...options, dryRun: false });
3774
+ ctx.ui.notify(
3775
+ `Removed ${removed.deleted.length} pi-babysit roots (${removed.bytes} bytes).`,
3776
+ "info",
3777
+ );
3778
+ return;
3779
+ }
3780
+ if (command) {
3781
+ ctx.ui.notify("Usage: /babysit or /babysit gc [days]", "error");
3782
+ return;
3783
+ }
2822
3784
  if (!(await babysitAvailable())) {
2823
3785
  ctx.ui.notify(babysitPreflightError ?? INSTALL_HINT, "error");
2824
3786
  return;
@@ -2860,16 +3822,15 @@ export default function (pi: ExtensionAPI) {
2860
3822
  // Inline snapshot (running) or summary (finished) — no tmux window.
2861
3823
  if (kind === "subagent") {
2862
3824
  // Parse the RPC event stream and show the final answer, not raw JSONL.
2863
- const prog = parseEvents((await bs(["log", "-s", picked.id])).stdout);
3825
+ const prog = taskProgressOf(picked.id).progress;
2864
3826
  const stats =
2865
- `turns=${prog.turns} tools=${prog.toolCalls.length}` +
3827
+ `turns=${prog.turns} calls=${prog.modelCalls} tools=${prog.toolCalls.length}` +
2866
3828
  (prog.tokens != null ? ` ctx=${prog.tokens}` : "") +
2867
- (prog.cost != null ? ` $${prog.cost.toFixed(4)}` : "");
3829
+ (prog.modelCalls > 0 ? ` usage=${prog.usageTokens} $${prog.cost.toFixed(4)}` : "");
2868
3830
  const body =
2869
3831
  (prog.finalText.trim() ||
2870
3832
  prog.errorMsg ||
2871
- (await bs(["log", "-s", picked.id, "--tail", "20"])).stdout.trim() ||
2872
- "(no output)") +
3833
+ `(no structured output; full log: ${logPath(picked.id)})`) +
2873
3834
  (picked.state === "running"
2874
3835
  ? "\n\n_Live subagent (read-only). Re-run `/babysit` to refresh this snapshot._"
2875
3836
  : "");