@yusukeshib/pi-babysit 0.3.13 → 0.3.15

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
@@ -148,16 +148,32 @@ const VIEW_CMD =
148
148
  // Appended to every subagent's system prompt. The subagent is a long-lived
149
149
  // headless `pi --mode rpc` worker: turns can end and resume, so babysit_run
150
150
  // (process kind) works normally inside it. It just cannot talk to a human.
151
- const SUBAGENT_GUIDANCE = [
152
- "You are a headless background worker driven over pi's RPC protocol.",
153
- "Work autonomously: you cannot ask the user questions, so state assumptions",
154
- "in your final answer instead. Long commands may be run synchronously via",
155
- "bash (blocking is fine) or via babysit_run (it works here). When your",
156
- "task is complete, produce a final answer message summarizing the outcome —",
157
- "your controller reads it from the event stream.",
158
- ].join(" ");
151
+ export function subagentGuidance(
152
+ depth: number,
153
+ maxDepth: number,
154
+ directBashAvailable: boolean,
155
+ babysitRunAvailable = true,
156
+ ): string {
157
+ const shellGuidance = directBashAvailable
158
+ ? "Direct bash is available, and babysit_run can supervise longer commands."
159
+ : babysitRunAvailable
160
+ ? "Direct bash is unavailable; run shell commands with babysit_run { command }."
161
+ : "No shell execution tool is available in this task's tool allowlist; do not attempt shell commands.";
162
+ const nestingGuidance = depth >= maxDepth
163
+ ? `You are at the inherited subagent depth limit (${depth}/${maxDepth}); do not attempt to spawn another subagent.`
164
+ : `Your inherited subagent depth is ${depth}/${maxDepth}; child subagents may not exceed maxDepth ${maxDepth}.`;
165
+ return [
166
+ "You are a headless background worker driven over pi's RPC protocol.",
167
+ "Work autonomously: you cannot ask the user questions, so state assumptions",
168
+ "in your final answer instead.",
169
+ shellGuidance,
170
+ nestingGuidance,
171
+ "When your task is complete, produce a final answer message summarizing the outcome —",
172
+ "your controller reads it from the event stream.",
173
+ ].join(" ");
174
+ }
159
175
  const POLL_MS = 2500;
160
- const QUICK_COMMAND_GRACE = process.env.PI_BABYSIT_QUICK_GRACE ?? "1s";
176
+ const QUICK_COMMAND_GRACE = process.env.PI_BABYSIT_QUICK_GRACE ?? "2s";
161
177
  const KILL_CONFIRM_TIMEOUT = "4s";
162
178
 
163
179
  interface BsSession {
@@ -301,17 +317,25 @@ async function listSessions(): Promise<{ sessions: BsSession[]; error?: string }
301
317
  }
302
318
  }
303
319
 
304
- async function statusOf(id: string): Promise<BsSession | null> {
320
+ async function lookupStatus(
321
+ id: string,
322
+ ): Promise<{ session: BsSession | null; error?: string }> {
305
323
  // `list --json` already carries every lifecycle field used by the extension,
306
324
  // including `alive` and `note`. Using it directly avoids the old
307
325
  // status-then-list pair (two CLI subprocesses for every status lookup).
308
326
  try {
309
- return (await listSessions()).sessions.find((session) => session.id === id) ?? null;
310
- } catch {
311
- return null;
327
+ const listed = await listSessions();
328
+ if (listed.error) return { session: null, error: listed.error };
329
+ return { session: listed.sessions.find((session) => session.id === id) ?? null };
330
+ } catch (error) {
331
+ return { session: null, error: error instanceof Error ? error.message : String(error) };
312
332
  }
313
333
  }
314
334
 
335
+ async function statusOf(id: string): Promise<BsSession | null> {
336
+ return (await lookupStatus(id)).session;
337
+ }
338
+
315
339
  export function isConfirmedTerminalState(state: string): boolean {
316
340
  return state === "killed" || state === "exited";
317
341
  }
@@ -347,11 +371,19 @@ async function awaitConfirmedTermination(id: string): Promise<BsSession | null>
347
371
  // kind=process: name/command + `notified` (exit notification dedup).
348
372
  // kind=subagent: task + the raw-log byte offset of the last prompt, which lets
349
373
  // check/wait analyze only the CURRENT task's events (important for follow-ups).
374
+ export interface SubagentBudget {
375
+ maxCost?: number;
376
+ maxTurns?: number;
377
+ maxToolCalls?: number;
378
+ maxUsageTokens?: number;
379
+ }
380
+
350
381
  interface Meta {
351
382
  kind: "process" | "subagent";
352
383
  // process
353
384
  name?: string;
354
385
  command?: string;
386
+ notificationGroup?: string;
355
387
  notified?: boolean;
356
388
  // A confirmed kill permanently owns completion delivery. An interrupted
357
389
  // concurrent wait must not re-enable the automatic notification afterward.
@@ -373,6 +405,10 @@ interface Meta {
373
405
  messageTempDirs?: Array<{ dir: string; afterAgentEnd: number }>;
374
406
  depth?: number;
375
407
  maxDepth?: number;
408
+ budget?: SubagentBudget;
409
+ budgetExceededAt?: number;
410
+ budgetReason?: string;
411
+ budgetKilled?: boolean;
376
412
  }
377
413
 
378
414
  const metaDir = () => path.join(ROOT, "meta");
@@ -395,6 +431,228 @@ function readMeta(id: string): Meta | null {
395
431
  }
396
432
  }
397
433
 
434
+ export interface BabysitGcResult {
435
+ candidates: string[];
436
+ deleted: string[];
437
+ bytes: number;
438
+ skippedLive: string[];
439
+ }
440
+
441
+ function processIsAlive(pid: number): boolean {
442
+ try {
443
+ process.kill(pid, 0);
444
+ return true;
445
+ } catch (error) {
446
+ return (error as NodeJS.ErrnoException).code === "EPERM";
447
+ }
448
+ }
449
+
450
+ const GC_LOCK_FILE = ".pi-babysit-gc.lock";
451
+ const ACTIVE_LEASE_PREFIX = ".pi-babysit-active-";
452
+
453
+ function scanTreeStats(root: string): { bytes: number; newestMtimeMs: number } {
454
+ let bytes = 0;
455
+ let newestMtimeMs = 0;
456
+ const pending = [root];
457
+ while (pending.length > 0) {
458
+ const current = pending.pop() as string;
459
+ let entries: fs.Dirent[];
460
+ try {
461
+ entries = fs.readdirSync(current, { withFileTypes: true });
462
+ } catch {
463
+ continue;
464
+ }
465
+ for (const entry of entries) {
466
+ if (entry.name === GC_LOCK_FILE) continue;
467
+ const fullPath = path.join(current, entry.name);
468
+ try {
469
+ const stat = fs.lstatSync(fullPath);
470
+ newestMtimeMs = Math.max(newestMtimeMs, stat.mtimeMs);
471
+ if (entry.isDirectory()) pending.push(fullPath);
472
+ else if (entry.isFile()) bytes += stat.size;
473
+ } catch {
474
+ /* raced with cleanup */
475
+ }
476
+ }
477
+ }
478
+ return { bytes, newestMtimeMs };
479
+ }
480
+
481
+ function gcRootIsSafe(root: string): boolean {
482
+ let rootEntries: fs.Dirent[];
483
+ try {
484
+ rootEntries = fs.readdirSync(root, { withFileTypes: true });
485
+ } catch {
486
+ return false;
487
+ }
488
+ for (const entry of rootEntries) {
489
+ if (!entry.isFile() || !entry.name.startsWith(ACTIVE_LEASE_PREFIX)) continue;
490
+ try {
491
+ const lease = JSON.parse(fs.readFileSync(path.join(root, entry.name), "utf8")) as {
492
+ pid?: number;
493
+ };
494
+ if (!Number.isSafeInteger(lease.pid) || (lease.pid as number) <= 0) return false;
495
+ if (processIsAlive(lease.pid as number)) return false;
496
+ } catch {
497
+ return false;
498
+ }
499
+ }
500
+
501
+ const sessionsDir = path.join(root, "sessions");
502
+ let sessionDirs: fs.Dirent[];
503
+ try {
504
+ sessionDirs = fs.readdirSync(sessionsDir, { withFileTypes: true });
505
+ } catch {
506
+ return false;
507
+ }
508
+ let sawStatus = false;
509
+ for (const sessionEntry of sessionDirs) {
510
+ if (!sessionEntry.isDirectory()) continue;
511
+ const sessionDir = path.join(sessionsDir, sessionEntry.name);
512
+ try {
513
+ const status = JSON.parse(
514
+ fs.readFileSync(path.join(sessionDir, "status.json"), "utf8"),
515
+ ) as { state?: string; child_pid?: number | null };
516
+ sawStatus = true;
517
+ if (status.state !== "running") {
518
+ if (!status.state || (!isConfirmedTerminalState(status.state) && status.state !== "dead")) {
519
+ return false;
520
+ }
521
+ continue;
522
+ }
523
+ const supervisorPid = Number(
524
+ (JSON.parse(fs.readFileSync(path.join(sessionDir, "meta.json"), "utf8")) as {
525
+ babysit_pid?: number;
526
+ }).babysit_pid,
527
+ );
528
+ const childPid = Number(status.child_pid);
529
+ if (
530
+ !Number.isSafeInteger(supervisorPid) ||
531
+ supervisorPid <= 0 ||
532
+ processIsAlive(supervisorPid) ||
533
+ (Number.isSafeInteger(childPid) && childPid > 0 && processIsAlive(childPid))
534
+ ) {
535
+ return false;
536
+ }
537
+ } catch {
538
+ return false;
539
+ }
540
+ }
541
+ return sawStatus;
542
+ }
543
+
544
+ function acquireRootLease(root: string): string | null {
545
+ fs.mkdirSync(root, { recursive: true });
546
+ const leasePath = path.join(
547
+ root,
548
+ `${ACTIVE_LEASE_PREFIX}${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
549
+ );
550
+ const sleeper = new Int32Array(new SharedArrayBuffer(4));
551
+ for (let attempt = 0; attempt < 100; attempt++) {
552
+ if (fs.existsSync(path.join(root, GC_LOCK_FILE))) {
553
+ Atomics.wait(sleeper, 0, 0, 50);
554
+ continue;
555
+ }
556
+ try {
557
+ fs.writeFileSync(leasePath, JSON.stringify({ pid: process.pid, startedAt: Date.now() }), {
558
+ flag: "wx",
559
+ });
560
+ if (!fs.existsSync(path.join(root, GC_LOCK_FILE))) return leasePath;
561
+ fs.rmSync(leasePath, { force: true });
562
+ } catch {
563
+ /* retry while a collector owns the namespace */
564
+ }
565
+ Atomics.wait(sleeper, 0, 0, 50);
566
+ }
567
+ return null;
568
+ }
569
+
570
+ function releaseRootLease(leasePath: string | undefined): void {
571
+ if (!leasePath) return;
572
+ try {
573
+ fs.rmSync(leasePath, { force: true });
574
+ } catch {
575
+ /* best-effort */
576
+ }
577
+ }
578
+
579
+ export function gcBabysitRoots(options: {
580
+ rootBase: string;
581
+ currentRoot: string;
582
+ olderThanMs: number;
583
+ dryRun?: boolean;
584
+ now?: number;
585
+ }): BabysitGcResult {
586
+ const now = options.now ?? Date.now();
587
+ const current = path.resolve(options.currentRoot);
588
+ const result: BabysitGcResult = { candidates: [], deleted: [], bytes: 0, skippedLive: [] };
589
+ let roots: fs.Dirent[];
590
+ try {
591
+ roots = fs.readdirSync(options.rootBase, { withFileTypes: true });
592
+ } catch {
593
+ return result;
594
+ }
595
+
596
+ for (const entry of roots) {
597
+ if (!entry.isDirectory() || entry.name.startsWith(".pi-babysit-gc-")) continue;
598
+ const root = path.join(options.rootBase, entry.name);
599
+ if (path.resolve(root) === current) continue;
600
+ if (!gcRootIsSafe(root)) {
601
+ result.skippedLive.push(entry.name);
602
+ continue;
603
+ }
604
+ const stats = scanTreeStats(root);
605
+ if (now - stats.newestMtimeMs < options.olderThanMs) continue;
606
+ result.candidates.push(entry.name);
607
+ if (options.dryRun !== false) {
608
+ result.bytes += stats.bytes;
609
+ continue;
610
+ }
611
+
612
+ const lockPath = path.join(root, GC_LOCK_FILE);
613
+ let lockFd: number | undefined;
614
+ let tombstone: string | undefined;
615
+ try {
616
+ lockFd = fs.openSync(lockPath, "wx");
617
+ // Compatible Pi processes acquire an active lease around this same lock.
618
+ // Revalidate after locking, then atomically rename the root so a resume
619
+ // racing deletion creates a fresh namespace instead of writing into rmSync.
620
+ if (!gcRootIsSafe(root)) {
621
+ result.skippedLive.push(entry.name);
622
+ continue;
623
+ }
624
+ const refreshedStats = scanTreeStats(root);
625
+ if (now - refreshedStats.newestMtimeMs < options.olderThanMs) continue;
626
+ tombstone = path.join(
627
+ options.rootBase,
628
+ `.pi-babysit-gc-${entry.name}-${process.pid}-${Date.now()}`,
629
+ );
630
+ fs.closeSync(lockFd);
631
+ lockFd = undefined;
632
+ fs.renameSync(root, tombstone);
633
+ fs.rmSync(tombstone, { recursive: true, force: true });
634
+ result.deleted.push(entry.name);
635
+ result.bytes += refreshedStats.bytes;
636
+ } catch {
637
+ /* report only roots whose atomic removal completed */
638
+ } finally {
639
+ if (lockFd != null) {
640
+ try {
641
+ fs.closeSync(lockFd);
642
+ } catch {
643
+ /* best-effort */
644
+ }
645
+ }
646
+ try {
647
+ fs.rmSync(lockPath, { force: true });
648
+ } catch {
649
+ /* root may already have been atomically renamed */
650
+ }
651
+ }
652
+ }
653
+ return result;
654
+ }
655
+
398
656
  function cleanupMessageTempDirs(id: string, meta: Meta, agentEnds: number): void {
399
657
  if (!meta.messageTempDirs?.length) return;
400
658
  const keep: NonNullable<Meta["messageTempDirs"]> = [];
@@ -420,6 +678,19 @@ export function shouldDeliverProcessCompletion(
420
678
  return meta?.kind === "process" && !meta.notified && !meta.notificationPaused;
421
679
  }
422
680
 
681
+ export function isNotificationGroupReady(
682
+ meta: Meta,
683
+ sessions: BsSession[],
684
+ metaFor: (id: string) => Meta | null,
685
+ ): boolean {
686
+ if (!meta.notificationGroup) return true;
687
+ return !sessions.some(
688
+ (session) =>
689
+ session.state === "running" &&
690
+ metaFor(session.id)?.notificationGroup === meta.notificationGroup,
691
+ );
692
+ }
693
+
423
694
  export function shouldDeferCompletionNotification(agentIsIdle: boolean): boolean {
424
695
  return !agentIsIdle;
425
696
  }
@@ -537,12 +808,19 @@ async function rpcResponse(
537
808
  if (e.code !== 0) {
538
809
  const st = await statusOf(id);
539
810
  if (st && st.state !== "running") {
540
- const tail = clip((await bs(["log", "-s", id, "--tail", "15"])).stdout.trim());
811
+ let structuredError = "";
812
+ try {
813
+ const bytes = fs.readFileSync(logPath(id));
814
+ structuredError = parseEvents(bytes.subarray(Math.min(since, bytes.length)).toString("utf8")).errorMsg ?? "";
815
+ } catch {
816
+ /* full log path below remains the diagnostic source */
817
+ }
541
818
  return {
542
819
  ok: false,
543
820
  error:
544
821
  `subagent exited (exit_code=${st.exit_code ?? "?"}) before responding to ${command}.` +
545
- (tail ? `\nLast output:\n${tail}` : ""),
822
+ (structuredError ? `\n${structuredError}` : "") +
823
+ `\nFull log: ${logPath(id)}`,
546
824
  };
547
825
  }
548
826
  return {
@@ -606,7 +884,10 @@ const INLINE_OUTPUT_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_INLINE_OUTPUT_MAX_B
606
884
  const NOTIFY_OUTPUT_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_OUTPUT_MAX_BYTES", 2_000);
607
885
  const NOTIFY_COMMAND_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_COMMAND_MAX_BYTES", 240);
608
886
  const NOTIFY_BATCH_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_BATCH_MAX_BYTES", 8_000);
609
- const ANSWER_MAX_BYTES = 24_000; // subagent answers / error messages
887
+ const ANSWER_MAX_BYTES = 24_000; // single subagent answers / structured error messages
888
+ const MAX_MULTI_WAIT_SESSIONS = 32;
889
+ const SUBAGENT_BUDGET_GRACE_MS =
890
+ parseDurMs(process.env.PI_BABYSIT_BUDGET_GRACE ?? "30s") ?? 30_000;
610
891
 
611
892
  export function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
612
893
  if (maxBytes <= 0) return "";
@@ -630,6 +911,13 @@ export function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
630
911
  return `${head}${marker}${tail}`;
631
912
  }
632
913
 
914
+ export function clipMultiWaitResult(
915
+ value: string,
916
+ maxBytes = INLINE_OUTPUT_MAX_BYTES,
917
+ ): string {
918
+ return clip(value, Math.min(maxBytes, ANSWER_MAX_BYTES));
919
+ }
920
+
633
921
  async function searchLog(
634
922
  id: string,
635
923
  pattern: string,
@@ -948,7 +1236,14 @@ export function deliverProcessCompletionMessage(
948
1236
  // for a subagent is a guidance violation, and treating it as completion keeps
949
1237
  // the parent from hanging forever.) `process` is the legacy pi-processes tool.
950
1238
  function isParkedMessages(
951
- messages: { role?: string; toolName?: string; content?: unknown }[] | undefined,
1239
+ messages:
1240
+ | {
1241
+ role?: string;
1242
+ toolName?: string;
1243
+ content?: unknown;
1244
+ details?: { kind?: string; status?: string };
1245
+ }[]
1246
+ | undefined,
952
1247
  ): boolean {
953
1248
  if (!messages) return false;
954
1249
  // Models sometimes add a short assistant note after starting a process. Scan
@@ -960,12 +1255,20 @@ function isParkedMessages(
960
1255
  if (message?.role !== "toolResult") continue;
961
1256
  if (message.toolName === "process") return true; // legacy pi-processes
962
1257
  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
1258
+ if (message.details?.kind === "process" && message.details.status === "started") {
1259
+ return true;
968
1260
  }
1261
+ const text = Array.isArray(message.content)
1262
+ ? message.content
1263
+ .filter((part): part is { type: "text"; text: string } =>
1264
+ Boolean(part && typeof part === "object" && part.type === "text" && typeof part.text === "string"),
1265
+ )
1266
+ .map((part) => part.text)
1267
+ .join("")
1268
+ : typeof message.content === "string"
1269
+ ? message.content
1270
+ : "";
1271
+ if (/^Process started \(id: [^)]+\)\. \[notify-on-exit\]\nLog: /.test(text)) return true;
969
1272
  }
970
1273
  return false;
971
1274
  }
@@ -982,8 +1285,17 @@ export interface Progress {
982
1285
  turns: number;
983
1286
  toolCalls: ToolCall[];
984
1287
  finalText: string;
1288
+ /** Context size reported by the most recent assistant response. */
985
1289
  tokens?: number;
986
- cost?: number;
1290
+ /** Cumulative model usage for the current subagent task. */
1291
+ modelCalls: number;
1292
+ usageTokens: number;
1293
+ inputTokens: number;
1294
+ outputTokens: number;
1295
+ cacheReadTokens: number;
1296
+ cacheWriteTokens: number;
1297
+ reasoningTokens: number;
1298
+ cost: number;
987
1299
  errorMsg?: string;
988
1300
  // RPC lifecycle bookkeeping (computed over the analyzed log slice):
989
1301
  agentStarts: number;
@@ -1027,6 +1339,14 @@ function emptyProgress(): Progress {
1027
1339
  turns: 0,
1028
1340
  toolCalls: [],
1029
1341
  finalText: "",
1342
+ modelCalls: 0,
1343
+ usageTokens: 0,
1344
+ inputTokens: 0,
1345
+ outputTokens: 0,
1346
+ cacheReadTokens: 0,
1347
+ cacheWriteTokens: 0,
1348
+ reasoningTokens: 0,
1349
+ cost: 0,
1030
1350
  agentStarts: 0,
1031
1351
  agentEnds: 0,
1032
1352
  agentSettled: 0,
@@ -1037,6 +1357,41 @@ function emptyProgress(): Progress {
1037
1357
  };
1038
1358
  }
1039
1359
 
1360
+ export function subagentBudgetViolation(
1361
+ progress: Progress,
1362
+ budget?: SubagentBudget,
1363
+ ): string | null {
1364
+ if (!budget) return null;
1365
+ if (budget.maxCost != null && progress.cost >= budget.maxCost) {
1366
+ return `cost $${progress.cost.toFixed(4)} reached maxCost $${budget.maxCost.toFixed(4)}`;
1367
+ }
1368
+ if (budget.maxTurns != null && progress.turns >= budget.maxTurns) {
1369
+ return `${progress.turns} turns reached maxTurns ${budget.maxTurns}`;
1370
+ }
1371
+ if (budget.maxToolCalls != null && progress.toolCalls.length >= budget.maxToolCalls) {
1372
+ return `${progress.toolCalls.length} tool calls reached maxToolCalls ${budget.maxToolCalls}`;
1373
+ }
1374
+ if (budget.maxUsageTokens != null && progress.usageTokens >= budget.maxUsageTokens) {
1375
+ return `${progress.usageTokens} usage tokens reached maxUsageTokens ${budget.maxUsageTokens}`;
1376
+ }
1377
+ return null;
1378
+ }
1379
+
1380
+ export function subagentBudgetAction(
1381
+ progress: Progress,
1382
+ budget: SubagentBudget | undefined,
1383
+ exceededAt: number | undefined,
1384
+ now: number,
1385
+ graceMs: number,
1386
+ ): { action: "none" | "steer" | "kill"; reason?: string } {
1387
+ const reason = subagentBudgetViolation(progress, budget);
1388
+ if (!reason) return { action: "none" };
1389
+ if (exceededAt == null) return { action: "steer", reason };
1390
+ return now - exceededAt >= graceMs
1391
+ ? { action: "kill", reason }
1392
+ : { action: "none", reason };
1393
+ }
1394
+
1040
1395
  function updateProgressState(progress: Progress): Progress {
1041
1396
  progress.running = progress.agentStarts > progress.agentEnds;
1042
1397
  progress.waitingOnProcess =
@@ -1073,7 +1428,19 @@ function parseEventLine(progress: Progress, raw: string): void {
1073
1428
  }
1074
1429
  case "message_end": {
1075
1430
  const message = event.message as
1076
- | { role?: string; content?: { type: string; text?: string }[]; usage?: { totalTokens?: number; cost?: { total?: number } } }
1431
+ | {
1432
+ role?: string;
1433
+ content?: { type: string; text?: string }[];
1434
+ usage?: {
1435
+ input?: number;
1436
+ output?: number;
1437
+ cacheRead?: number;
1438
+ cacheWrite?: number;
1439
+ reasoning?: number;
1440
+ totalTokens?: number;
1441
+ cost?: { total?: number };
1442
+ };
1443
+ }
1077
1444
  | undefined;
1078
1445
  if (message?.role === "assistant") {
1079
1446
  const text = (message.content ?? [])
@@ -1082,8 +1449,17 @@ function parseEventLine(progress: Progress, raw: string): void {
1082
1449
  .join("");
1083
1450
  if (text.trim()) progress.finalText = text;
1084
1451
  if (message.usage) {
1452
+ const finite = (value: number | undefined) =>
1453
+ typeof value === "number" && Number.isFinite(value) ? value : 0;
1454
+ progress.modelCalls++;
1085
1455
  progress.tokens = message.usage.totalTokens;
1086
- progress.cost = message.usage.cost?.total;
1456
+ progress.usageTokens += finite(message.usage.totalTokens);
1457
+ progress.inputTokens += finite(message.usage.input);
1458
+ progress.outputTokens += finite(message.usage.output);
1459
+ progress.cacheReadTokens += finite(message.usage.cacheRead);
1460
+ progress.cacheWriteTokens += finite(message.usage.cacheWrite);
1461
+ progress.reasoningTokens += finite(message.usage.reasoning);
1462
+ progress.cost += finite(message.usage.cost?.total);
1087
1463
  }
1088
1464
  }
1089
1465
  break;
@@ -1093,11 +1469,19 @@ function parseEventLine(progress: Progress, raw: string): void {
1093
1469
  break;
1094
1470
  case "agent_end": {
1095
1471
  progress.agentEnds++;
1096
- progress.lastEndWasProcessWait = isParkedMessages(
1097
- event.messages as
1098
- | { role?: string; toolName?: string; content?: unknown }[]
1099
- | undefined,
1100
- );
1472
+ progress.lastEndWasProcessWait =
1473
+ typeof event.piBabysitParked === "boolean"
1474
+ ? event.piBabysitParked
1475
+ : isParkedMessages(
1476
+ event.messages as
1477
+ | {
1478
+ role?: string;
1479
+ toolName?: string;
1480
+ content?: unknown;
1481
+ details?: { kind?: string; status?: string };
1482
+ }[]
1483
+ | undefined,
1484
+ );
1101
1485
  break;
1102
1486
  }
1103
1487
  case "agent_settled":
@@ -1111,6 +1495,11 @@ function parseEventLine(progress: Progress, raw: string): void {
1111
1495
  case "error":
1112
1496
  progress.errorMsg = String(event.message ?? event.error ?? line);
1113
1497
  break;
1498
+ case "extension_error": {
1499
+ const extension = event.extensionPath ? `${String(event.extensionPath)}: ` : "";
1500
+ progress.errorMsg = `${extension}${String(event.error ?? "extension failed")}`;
1501
+ break;
1502
+ }
1114
1503
  }
1115
1504
  }
1116
1505
 
@@ -1120,6 +1509,35 @@ export function parseEvents(logText: string): Progress {
1120
1509
  return updateProgressState(progress);
1121
1510
  }
1122
1511
 
1512
+ export function buildSubagentDoneResult(
1513
+ progress: Progress,
1514
+ ): { body: string; ok: boolean } {
1515
+ const finalText = progress.finalText.trim();
1516
+ return {
1517
+ body: clip(
1518
+ progress.errorMsg
1519
+ ? `Extension error: ${progress.errorMsg}` +
1520
+ (finalText ? `\n\n--- final answer ---\n${finalText}` : "")
1521
+ : finalText || "(no answer text)",
1522
+ ANSWER_MAX_BYTES,
1523
+ ),
1524
+ ok: !progress.errorMsg,
1525
+ };
1526
+ }
1527
+
1528
+ export function buildSubagentExitDiagnostic(
1529
+ progress: Progress,
1530
+ fullLogPath: string,
1531
+ ): string {
1532
+ const body = clip(
1533
+ progress.errorMsg ||
1534
+ progress.finalText.trim() ||
1535
+ "(no structured error was emitted; inspect the full log)",
1536
+ ANSWER_MAX_BYTES,
1537
+ );
1538
+ return `${body}\n\nFull log: ${fullLogPath}`;
1539
+ }
1540
+
1123
1541
  interface TaskProgressCache {
1124
1542
  base: number;
1125
1543
  offset: number;
@@ -1164,8 +1582,12 @@ function taskProgressOf(id: string): { progress: Progress; offset: number } {
1164
1582
  cached.pending = Buffer.from(bytes.subarray(start));
1165
1583
  }
1166
1584
  updateProgressState(cached.progress);
1585
+ const currentMeta = readMeta(id);
1586
+ if (currentMeta?.budgetKilled && currentMeta.budgetReason) {
1587
+ cached.progress.errorMsg = `Subagent budget exceeded: ${currentMeta.budgetReason}`;
1588
+ }
1167
1589
  if (cached.progress.agentEnds > 0) {
1168
- const meta = readMeta(id);
1590
+ const meta = currentMeta;
1169
1591
  if (meta) cleanupMessageTempDirs(id, meta, cached.progress.agentEnds);
1170
1592
  }
1171
1593
  // If the writer was observed mid-record, let `babysit expect --since` start
@@ -1181,13 +1603,16 @@ function taskProgressOf(id: string): { progress: Progress; offset: number } {
1181
1603
  // ---------------------------------------------------------------------------
1182
1604
 
1183
1605
  // Friendly name → unique babysit session id (babysit ids allow [\w.-]).
1184
- async function uniqueSessionId(name: string): Promise<string> {
1606
+ const reservedSessionIds = new Set<string>();
1607
+
1608
+ async function reserveUniqueSessionId(name: string): Promise<string> {
1185
1609
  const base = name.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "") || "proc";
1186
1610
  const taken = new Set((await listSessions()).sessions.map((s) => s.id));
1187
- if (!taken.has(base)) return base;
1188
- for (let i = 2; ; i++) {
1189
- if (!taken.has(`${base}-${i}`)) return `${base}-${i}`;
1190
- }
1611
+ for (const id of reservedSessionIds) taken.add(id);
1612
+ let id = base;
1613
+ for (let i = 2; taken.has(id); i++) id = `${base}-${i}`;
1614
+ reservedSessionIds.add(id);
1615
+ return id;
1191
1616
  }
1192
1617
 
1193
1618
  interface ProcOpts {
@@ -1197,6 +1622,7 @@ interface ProcOpts {
1197
1622
  timeout?: string; // default: none — dev servers may run indefinitely
1198
1623
  idleTimeout?: string;
1199
1624
  pty: boolean;
1625
+ notificationGroup?: string;
1200
1626
  }
1201
1627
 
1202
1628
  async function spawnProcess(opts: ProcOpts): Promise<{ id: string } | { error: string }> {
@@ -1205,10 +1631,16 @@ async function spawnProcess(opts: ProcOpts): Promise<{ id: string } | { error: s
1205
1631
  if (opts.timeout && opts.timeout !== "none") bsArgs.push("--timeout", opts.timeout);
1206
1632
  if (opts.idleTimeout && opts.idleTimeout !== "none")
1207
1633
  bsArgs.push("--idle-timeout", opts.idleTimeout);
1208
- if (opts.name) bsArgs.push("--id", await uniqueSessionId(opts.name));
1634
+ const reservedId = opts.name ? await reserveUniqueSessionId(opts.name) : undefined;
1635
+ if (reservedId) bsArgs.push("--id", reservedId);
1209
1636
  bsArgs.push("--", SHELL, "-c", opts.command);
1210
1637
 
1211
- const r = await bs(bsArgs, { cwd: opts.cwd });
1638
+ let r: Awaited<ReturnType<typeof bs>>;
1639
+ try {
1640
+ r = await bs(bsArgs, { cwd: opts.cwd });
1641
+ } finally {
1642
+ if (reservedId) reservedSessionIds.delete(reservedId);
1643
+ }
1212
1644
  if (r.code !== 0) {
1213
1645
  return {
1214
1646
  error:
@@ -1226,6 +1658,7 @@ async function spawnProcess(opts: ProcOpts): Promise<{ id: string } | { error: s
1226
1658
  kind: "process",
1227
1659
  name: opts.name ?? id,
1228
1660
  command: opts.command,
1661
+ notificationGroup: opts.notificationGroup,
1229
1662
  notified: false,
1230
1663
  startedAt: Date.now(),
1231
1664
  });
@@ -1279,6 +1712,7 @@ function discardDelivery(delivery: DeliverableMessage): void {
1279
1712
  }
1280
1713
 
1281
1714
  interface SubagentOpts {
1715
+ name?: string;
1282
1716
  agent?: AgentConfig;
1283
1717
  task: string;
1284
1718
  model?: string;
@@ -1286,6 +1720,7 @@ interface SubagentOpts {
1286
1720
  cwd: string;
1287
1721
  depth: number;
1288
1722
  maxDepth: number;
1723
+ budget?: SubagentBudget;
1289
1724
  // Idle-timeout is OFF by default: an RPC-mode pi is silent while it works,
1290
1725
  // so idle detection would false-kill a busy subagent. The absolute timeout
1291
1726
  // is the safety valve instead.
@@ -1311,7 +1746,15 @@ async function spawnSubagent(
1311
1746
  };
1312
1747
  }
1313
1748
  if (tools && tools.length > 0) piArgs.push("--tools", tools.join(","));
1314
- piArgs.push("--append-system-prompt", SUBAGENT_GUIDANCE);
1749
+ piArgs.push(
1750
+ "--append-system-prompt",
1751
+ subagentGuidance(
1752
+ opts.depth,
1753
+ opts.maxDepth,
1754
+ isAllowedDirectBash("") && (!tools || tools.includes("bash")),
1755
+ !tools || tools.includes("babysit_run"),
1756
+ ),
1757
+ );
1315
1758
  let promptTempFile: string | undefined;
1316
1759
  if (opts.agent?.systemPrompt?.trim()) {
1317
1760
  promptTempFile = writePromptTempFile(opts.agent.name, opts.agent.systemPrompt);
@@ -1343,12 +1786,14 @@ async function spawnSubagent(
1343
1786
  opts.timeout,
1344
1787
  ];
1345
1788
  // Pretty-print the compact JSONL stream for humans who `attach`. The RPC
1346
- // proxy removes only cumulative streaming snapshots; authoritative final,
1347
- // tool, lifecycle, response, and error events remain intact for parseEvents.
1789
+ // proxy retains authoritative message_end/response/error events and reduces
1790
+ // redundant lifecycle/tool payloads; parseEvents accepts both log formats.
1348
1791
  if (VIEW_CMD.trim()) bsArgs.push("--view-cmd", VIEW_CMD);
1349
1792
  if (opts.idleTimeout && opts.idleTimeout !== "none") {
1350
1793
  bsArgs.push("--idle-timeout", opts.idleTimeout);
1351
1794
  }
1795
+ const reservedId = opts.name ? await reserveUniqueSessionId(opts.name) : undefined;
1796
+ if (reservedId) bsArgs.push("--id", reservedId);
1352
1797
  bsArgs.push(
1353
1798
  "--",
1354
1799
  process.execPath,
@@ -1358,13 +1803,18 @@ async function spawnSubagent(
1358
1803
  ...piArgs,
1359
1804
  );
1360
1805
 
1361
- const r = await bs(bsArgs, {
1362
- cwd: opts.cwd,
1363
- env: {
1364
- [SUBAGENT_DEPTH_ENV]: String(opts.depth),
1365
- [SUBAGENT_MAX_DEPTH_ENV]: String(opts.maxDepth),
1366
- },
1367
- });
1806
+ let r: Awaited<ReturnType<typeof bs>>;
1807
+ try {
1808
+ r = await bs(bsArgs, {
1809
+ cwd: opts.cwd,
1810
+ env: {
1811
+ [SUBAGENT_DEPTH_ENV]: String(opts.depth),
1812
+ [SUBAGENT_MAX_DEPTH_ENV]: String(opts.maxDepth),
1813
+ },
1814
+ });
1815
+ } finally {
1816
+ if (reservedId) reservedSessionIds.delete(reservedId);
1817
+ }
1368
1818
  if (r.code !== 0) {
1369
1819
  cleanupPromptTemp();
1370
1820
  return { error: r.stderr || r.stdout || `babysit run failed (exit ${r.code}, no output) — check that \`${BABYSIT_BIN}\` works and ${ROOT} is writable` };
@@ -1383,10 +1833,12 @@ async function spawnSubagent(
1383
1833
  // The success path overwrites this with the full task meta.
1384
1834
  writeMeta(id, {
1385
1835
  kind: "subagent",
1836
+ name: opts.name ?? id,
1386
1837
  task: opts.task,
1387
1838
  notified: true,
1388
1839
  depth: opts.depth,
1389
1840
  maxDepth: opts.maxDepth,
1841
+ budget: opts.budget,
1390
1842
  });
1391
1843
 
1392
1844
  // Wait for pi to boot (first JSON event in the log), then inject the task.
@@ -1441,6 +1893,7 @@ async function spawnSubagent(
1441
1893
 
1442
1894
  writeMeta(id, {
1443
1895
  kind: "subagent",
1896
+ name: opts.name ?? id,
1444
1897
  task: opts.task,
1445
1898
  promptOffset: resp.offset,
1446
1899
  model: resolvedModel,
@@ -1450,6 +1903,7 @@ async function spawnSubagent(
1450
1903
  : undefined,
1451
1904
  depth: opts.depth,
1452
1905
  maxDepth: opts.maxDepth,
1906
+ budget: opts.budget,
1453
1907
  startedAt: Date.now(),
1454
1908
  });
1455
1909
  return { id, model: resolvedModel };
@@ -1562,43 +2016,39 @@ async function waitForTask(
1562
2016
  const st = await statusOf(id);
1563
2017
 
1564
2018
  const stats =
1565
- `turns=${prog.turns} tools=${prog.toolCalls.length}` +
2019
+ `turns=${prog.turns} calls=${prog.modelCalls} tools=${prog.toolCalls.length}` +
1566
2020
  (prog.tokens != null ? ` ctx=${prog.tokens}` : "") +
1567
- (prog.cost != null ? ` $${prog.cost.toFixed(4)}` : "");
2021
+ (prog.modelCalls > 0
2022
+ ? ` usage=${prog.usageTokens} (in=${prog.inputTokens} out=${prog.outputTokens} cache=${prog.cacheReadTokens}) $${prog.cost.toFixed(4)}`
2023
+ : "");
1568
2024
 
1569
2025
  if (prog.done) {
1570
- const body = clip(
1571
- prog.finalText.trim() || prog.errorMsg || "(no answer text)",
1572
- ANSWER_MAX_BYTES,
1573
- );
1574
- const ok = prog.finalText.trim().length > 0 || !prog.errorMsg;
2026
+ const completed = buildSubagentDoneResult(prog);
1575
2027
  return {
1576
2028
  id,
1577
2029
  kind: "done",
1578
- ok,
2030
+ ok: completed.ok,
1579
2031
  text:
1580
2032
  `Subagent ${id} finished its task (${stats}).\n` +
1581
2033
  `Session stays alive — follow-up: babysit_send { id: "${id}" }, ` +
1582
- `or babysit_kill when done.\n\n${body}`,
2034
+ `or babysit_kill when done.\n\n${completed.body}`,
1583
2035
  status: st,
1584
2036
  progress: prog,
1585
2037
  };
1586
2038
  }
1587
2039
 
1588
2040
  if (!st || st.state !== "running") {
1589
- // Crash / timeout / external kill — make the cause visible.
1590
- const tail = clip((await bs(["log", "-s", id, "--tail", "20"])).stdout.trim());
1591
- const body = clip(
1592
- prog.finalText.trim() || prog.errorMsg || tail || "(no output)",
1593
- ANSWER_MAX_BYTES,
1594
- );
2041
+ // Never inject a raw RPC JSON tail into model context. Structured errors
2042
+ // are parsed above; the complete stream remains available at the log path.
2043
+ const diagnostic = buildSubagentExitDiagnostic(prog, logPath(id));
1595
2044
  return {
1596
2045
  id,
1597
2046
  kind: "exited",
1598
2047
  ok: false,
1599
2048
  text:
1600
2049
  `Subagent ${id} EXITED before completing the task ` +
1601
- `(state=${st?.state ?? "missing"}, exit_code=${st?.exit_code ?? "?"}, ${stats}).\n\n${body}`,
2050
+ `(state=${st?.state ?? "missing"}, exit_code=${st?.exit_code ?? "?"}, ${stats}).\n\n` +
2051
+ diagnostic,
1602
2052
  status: st,
1603
2053
  progress: prog,
1604
2054
  };
@@ -1729,9 +2179,15 @@ async function waitForExit(
1729
2179
 
1730
2180
  if (expectPattern) {
1731
2181
  const e = await bs(["expect", "-s", id, "--timeout", t, expectPattern], { signal });
1732
- if (signal?.aborted || e.code === 130) {
2182
+ if (signal?.aborted) {
1733
2183
  return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
1734
2184
  }
2185
+ if (e.code === 130) {
2186
+ const interruptedStatus = await statusOf(id);
2187
+ if (interruptedStatus?.state === "running") {
2188
+ return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
2189
+ }
2190
+ }
1735
2191
  if (e.code === 0) {
1736
2192
  return {
1737
2193
  id,
@@ -1757,10 +2213,17 @@ async function waitForExit(
1757
2213
  // another wait that is still pending.
1758
2214
  updateWaitReservation(id, "reserve");
1759
2215
  const w = await bs(["wait", "-s", id, "--timeout", t], { signal });
1760
- if (signal?.aborted || w.code === 130) {
2216
+ if (signal?.aborted) {
1761
2217
  updateWaitReservation(id, "abandon");
1762
2218
  return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
1763
2219
  }
2220
+ if (w.code === 130) {
2221
+ const interruptedStatus = await statusOf(id);
2222
+ if (interruptedStatus?.state === "running") {
2223
+ updateWaitReservation(id, "abandon");
2224
+ return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
2225
+ }
2226
+ }
1764
2227
  if (w.code === 124) {
1765
2228
  // 124 is ambiguous (timeout vs child exiting 124) — disambiguate.
1766
2229
  const st0 = await statusOf(id);
@@ -1777,12 +2240,20 @@ async function waitForExit(
1777
2240
  }
1778
2241
  }
1779
2242
 
1780
- const st = await statusOf(id);
2243
+ const statusLookup = await lookupStatus(id);
2244
+ if (statusLookup.error) {
2245
+ // The registry could not be read, so do not convert a transient backend
2246
+ // failure into a missing session or permanently claim its notification.
2247
+ if (!expectPattern) updateWaitReservation(id, "abandon");
2248
+ return {
2249
+ id,
2250
+ kind: "interrupted",
2251
+ ok: false,
2252
+ text: `Could not verify ${id} after waiting: ${statusLookup.error}`,
2253
+ };
2254
+ }
2255
+ const st = statusLookup.session;
1781
2256
  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
2257
  if (!expectPattern) updateWaitReservation(id, "claim");
1787
2258
  return { id, kind: "exited", ok: false, text: `No such session: ${id}` };
1788
2259
  }
@@ -1843,6 +2314,44 @@ export function activeToolsWithoutDirectBash(activeTools: string[], allowDirectB
1843
2314
  return allowDirectBash ? activeTools : activeTools.filter((name) => name !== "bash");
1844
2315
  }
1845
2316
 
2317
+ export function automaticNotificationGroup(entry: unknown): string | undefined {
2318
+ const candidate = entry as {
2319
+ id?: unknown;
2320
+ type?: unknown;
2321
+ message?: { role?: unknown; content?: unknown };
2322
+ };
2323
+ if (
2324
+ candidate?.type !== "message" ||
2325
+ candidate.message?.role !== "assistant" ||
2326
+ typeof candidate.id !== "string" ||
2327
+ !Array.isArray(candidate.message.content)
2328
+ ) {
2329
+ return undefined;
2330
+ }
2331
+ const group = `turn-${candidate.id}`;
2332
+ const runs = candidate.message.content.filter((part) => {
2333
+ if (!part || typeof part !== "object") return false;
2334
+ const call = part as {
2335
+ type?: unknown;
2336
+ name?: unknown;
2337
+ arguments?: Record<string, unknown>;
2338
+ };
2339
+ if (call.type !== "toolCall" || call.name !== "babysit_run") return false;
2340
+ const args = call.arguments ?? {};
2341
+ const existing = typeof args.notificationGroup === "string"
2342
+ ? args.notificationGroup.trim()
2343
+ : "";
2344
+ return (
2345
+ typeof args.command === "string" &&
2346
+ args.command.length > 0 &&
2347
+ args.profile !== "subagent" &&
2348
+ args.foreground !== true &&
2349
+ (existing === "" || existing === group)
2350
+ );
2351
+ });
2352
+ return runs.length >= 2 ? group : undefined;
2353
+ }
2354
+
1846
2355
  // ---------------------------------------------------------------------------
1847
2356
  // extension
1848
2357
  // ---------------------------------------------------------------------------
@@ -1850,7 +2359,27 @@ export function activeToolsWithoutDirectBash(activeTools: string[], allowDirectB
1850
2359
  export default function (pi: ExtensionAPI) {
1851
2360
  let pollTimer: ReturnType<typeof setInterval> | undefined;
1852
2361
  let pollNeeded = true;
2362
+ let automaticGcRan = false;
2363
+ let rootLeasePath: string | undefined;
1853
2364
  const declaredToolErrors = new Set<string>();
2365
+ const sessionRpcTails = new Map<string, Promise<void>>();
2366
+
2367
+ async function withSessionRpcLock<T>(id: string, operation: () => Promise<T>): Promise<T> {
2368
+ const previous = sessionRpcTails.get(id) ?? Promise.resolve();
2369
+ let release!: () => void;
2370
+ const gate = new Promise<void>((resolve) => {
2371
+ release = resolve;
2372
+ });
2373
+ const tail = previous.then(() => gate);
2374
+ sessionRpcTails.set(id, tail);
2375
+ await previous;
2376
+ try {
2377
+ return await operation();
2378
+ } finally {
2379
+ release();
2380
+ if (sessionRpcTails.get(id) === tail) sessionRpcTails.delete(id);
2381
+ }
2382
+ }
1854
2383
 
1855
2384
  // Pi only persists custom-tool failures when they are thrown or patched by a
1856
2385
  // tool_result hook; an `isError` property returned from execute() is ignored.
@@ -1876,6 +2405,81 @@ export default function (pi: ExtensionAPI) {
1876
2405
  if (declaredToolErrors.delete(event.toolCallId)) return { isError: true };
1877
2406
  });
1878
2407
 
2408
+ async function enforceSubagentBudgets(sessions: BsSession[]): Promise<void> {
2409
+ for (const session of sessions) {
2410
+ if (session.state !== "running") continue;
2411
+ await withSessionRpcLock(session.id, async () => {
2412
+ const latestStatus = await statusOf(session.id);
2413
+ const meta = readMeta(session.id);
2414
+ if (
2415
+ latestStatus?.state !== "running" ||
2416
+ meta?.kind !== "subagent" ||
2417
+ !meta.budget ||
2418
+ meta.budgetKilled
2419
+ ) {
2420
+ return;
2421
+ }
2422
+
2423
+ let progress: Progress;
2424
+ try {
2425
+ progress = taskProgressOf(session.id).progress;
2426
+ } catch {
2427
+ return;
2428
+ }
2429
+ if (progress.done) return;
2430
+ const now = Date.now();
2431
+ const decision = subagentBudgetAction(
2432
+ progress,
2433
+ meta.budget,
2434
+ meta.budgetExceededAt,
2435
+ now,
2436
+ SUBAGENT_BUDGET_GRACE_MS,
2437
+ );
2438
+ if (decision.action === "none") return;
2439
+ const reason = decision.reason as string;
2440
+
2441
+ if (decision.action === "steer") {
2442
+ // Start the grace period only after Pi accepts the steering command.
2443
+ const sent = await sendRpc(session.id, {
2444
+ type: "steer",
2445
+ message: `Budget reached (${reason}). Stop calling tools and provide your final answer now.`,
2446
+ });
2447
+ if ("error" in sent) return;
2448
+ const accepted = await rpcResponse(session.id, sent.offset, "steer", "3s");
2449
+ if (!accepted.ok) return;
2450
+ const current = readMeta(session.id);
2451
+ if (
2452
+ current?.kind !== "subagent" ||
2453
+ current.promptOffset !== meta.promptOffset ||
2454
+ current.budgetExceededAt
2455
+ ) {
2456
+ return;
2457
+ }
2458
+ current.budgetExceededAt = now;
2459
+ current.budgetReason = reason;
2460
+ writeMeta(session.id, current);
2461
+ return;
2462
+ }
2463
+
2464
+ const killed = await bs(["kill", "-s", session.id, "--json"]);
2465
+ if (killed.code !== 0) return;
2466
+ const terminal = await awaitConfirmedTermination(session.id);
2467
+ const current = readMeta(session.id);
2468
+ if (
2469
+ terminal &&
2470
+ isConfirmedTerminalState(terminal.state) &&
2471
+ current?.kind === "subagent" &&
2472
+ current.promptOffset === meta.promptOffset &&
2473
+ current.budgetExceededAt === meta.budgetExceededAt
2474
+ ) {
2475
+ current.budgetKilled = true;
2476
+ current.budgetReason = current.budgetReason ?? reason;
2477
+ writeMeta(session.id, current);
2478
+ }
2479
+ });
2480
+ }
2481
+ }
2482
+
1879
2483
  // Exit notifications for kind=process sessions: the poller detects
1880
2484
  // running→exited transitions and injects ONE message (triggerTurn) for all
1881
2485
  // processes that became deliverable in the same poll. This resumes an agent
@@ -1896,6 +2500,9 @@ export default function (pi: ExtensionAPI) {
1896
2500
  if (session.state === "running") continue;
1897
2501
  const meta = readMeta(session.id);
1898
2502
  if (!shouldDeliverProcessCompletion(meta)) continue;
2503
+ // A notification group is delivered only after all currently known
2504
+ // members have stopped, even when their exit times span many polls.
2505
+ if (!isNotificationGroupReady(meta, sessions, readMeta)) continue;
1899
2506
  // Delay delivery by one poll interval. This gives an agent that chose
1900
2507
  // babysit_wait immediately after babysit_run enough time to claim the
1901
2508
  // completion and suppress the otherwise duplicate automatic message.
@@ -1938,13 +2545,17 @@ export default function (pi: ExtensionAPI) {
1938
2545
  });
1939
2546
  }
1940
2547
 
1941
- // Output loading above is asynchronous. Re-read every candidate together
1942
- // immediately before the single send so a concurrent kill/wait cannot claim
1943
- // an early candidate while a later candidate's output is being loaded.
2548
+ // Output loading above is asynchronous. Refresh sessions and metadata
2549
+ // immediately before the single send so concurrent wait/kill calls and newly
2550
+ // started notification-group members are honored.
2551
+ const refreshed = await listSessions();
2552
+ if (refreshed.error) return;
2553
+ const finalSessions = refreshed.sessions;
1944
2554
  const metadataById = new Map<string, Meta>();
1945
2555
  const notices = prepared.flatMap((notice) => {
1946
2556
  const current = readMeta(notice.id);
1947
2557
  if (!shouldDeliverProcessCompletion(current)) return [];
2558
+ if (!isNotificationGroupReady(current, finalSessions, readMeta)) return [];
1948
2559
  metadataById.set(notice.id, current);
1949
2560
  return [{ ...notice, command: current.command }];
1950
2561
  });
@@ -2092,13 +2703,42 @@ export default function (pi: ExtensionAPI) {
2092
2703
  // Session-local registry: scope the babysit root to this pi session so
2093
2704
  // other sessions' processes/subagents are invisible here. Resuming a
2094
2705
  // session keeps the same id, so its sessions come back with it.
2706
+ releaseRootLease(rootLeasePath);
2095
2707
  try {
2096
2708
  ROOT = path.join(ROOT_BASE, ctx.sessionManager.getSessionId());
2097
2709
  } catch {
2098
2710
  ROOT = ROOT_BASE;
2099
2711
  }
2712
+ rootLeasePath = acquireRootLease(ROOT) ?? undefined;
2713
+ if (!rootLeasePath) {
2714
+ const desiredRoot = ROOT;
2715
+ ROOT = `${desiredRoot}-active-${process.pid}-${Date.now()}`;
2716
+ rootLeasePath = acquireRootLease(ROOT) ?? undefined;
2717
+ if (ctx.hasUI) {
2718
+ ctx.ui.notify(
2719
+ `Babysit GC was busy for ${desiredRoot}; using an isolated root for this run.`,
2720
+ "warning",
2721
+ );
2722
+ }
2723
+ }
2100
2724
  taskProgressCache.clear();
2101
2725
  pollNeeded = true;
2726
+ const retentionDays = Number(process.env.PI_BABYSIT_RETENTION_DAYS);
2727
+ if (!automaticGcRan && Number.isFinite(retentionDays) && retentionDays > 0) {
2728
+ automaticGcRan = true;
2729
+ const gc = gcBabysitRoots({
2730
+ rootBase: ROOT_BASE,
2731
+ currentRoot: ROOT,
2732
+ olderThanMs: retentionDays * 86_400_000,
2733
+ dryRun: false,
2734
+ });
2735
+ if (gc.deleted.length > 0 && ctx.hasUI) {
2736
+ ctx.ui.notify(
2737
+ `pi-babysit GC removed ${gc.deleted.length} roots (${gc.bytes} bytes).`,
2738
+ "info",
2739
+ );
2740
+ }
2741
+ }
2102
2742
  // Warn early if the binary is missing so the user isn't surprised only when
2103
2743
  // a tool later fails. Tools/commands still enforce it via requireBabysit.
2104
2744
  if (ctx.hasUI && !(await babysitAvailable())) {
@@ -2118,6 +2758,7 @@ export default function (pi: ExtensionAPI) {
2118
2758
  throw new Error(listed.error);
2119
2759
  }
2120
2760
  const snapshot = listed.sessions;
2761
+ await enforceSubagentBudgets(snapshot);
2121
2762
  await Promise.all([
2122
2763
  notifyEndedProcesses(ctx, snapshot),
2123
2764
  refreshWidget(ctx, snapshot),
@@ -2137,9 +2778,29 @@ export default function (pi: ExtensionAPI) {
2137
2778
  pi.on("session_shutdown", async () => {
2138
2779
  if (pollTimer) clearInterval(pollTimer);
2139
2780
  pollTimer = undefined;
2781
+ releaseRootLease(rootLeasePath);
2782
+ rootLeasePath = undefined;
2140
2783
  });
2141
2784
 
2142
- pi.on("tool_call", async (event) => {
2785
+ pi.on("tool_call", async (event, ctx) => {
2786
+ if (event.toolName === "babysit_run") {
2787
+ const input = event.input as {
2788
+ command?: unknown;
2789
+ profile?: unknown;
2790
+ foreground?: unknown;
2791
+ notificationGroup?: unknown;
2792
+ };
2793
+ if (
2794
+ typeof input.command === "string" &&
2795
+ input.profile !== "subagent" &&
2796
+ input.foreground !== true &&
2797
+ (typeof input.notificationGroup !== "string" || input.notificationGroup.trim() === "")
2798
+ ) {
2799
+ const group = automaticNotificationGroup(ctx.sessionManager.getLeafEntry());
2800
+ if (group) input.notificationGroup = group;
2801
+ }
2802
+ return;
2803
+ }
2143
2804
  if (event.toolName !== "bash") return;
2144
2805
  const command = String((event.input as { command?: unknown }).command ?? "");
2145
2806
  if (backgroundsItself(command)) {
@@ -2165,10 +2826,12 @@ export default function (pi: ExtensionAPI) {
2165
2826
  name: "babysit_run",
2166
2827
  label: "Babysit: run",
2167
2828
  description:
2168
- "Run any shell command in a supervised babysit session. Commands that finish within a short " +
2169
- "grace period return completion metadata immediately; longer commands continue in the background " +
2170
- "and trigger an automatic notification on exit. Complete output is returned inline only when it is " +
2171
- "small; larger output stays in the log path for bounded inspection with babysit_check. " +
2829
+ "Run any shell command in a supervised babysit session. Set `foreground: true` when the next step " +
2830
+ "needs the exit result in this tool call. Otherwise commands that finish within a short grace period " +
2831
+ "return completion metadata immediately; longer commands continue in the background and trigger an " +
2832
+ "automatic notification on exit. Sibling background runs in one assistant message are grouped " +
2833
+ "automatically. Complete output is returned inline only when it is small; larger output stays " +
2834
+ "in the log path for bounded inspection with babysit_check. " +
2172
2835
  "In non-interactive mode (`pi -p`, no UI), process mode blocks until exit because there is no " +
2173
2836
  "notification loop. Two modes: (1) `command` — run any shell command, including builds, tests, " +
2174
2837
  "dev servers, watchers, and interactive TUIs; you can type into it with babysit_send and read " +
@@ -2181,13 +2844,15 @@ export default function (pi: ExtensionAPI) {
2181
2844
  promptSnippet:
2182
2845
  "Run any shell command with context-safe captured output; quick commands return metadata, longer ones continue in background",
2183
2846
  promptGuidelines: [
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`.",
2847
+ "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 every meaningful process or subagent a clear stable `name`.",
2848
+ "Use babysit_run { command, foreground: true } when the result is required before the next step; this avoids a separate babysit_wait model turn. Do not use foreground for servers, watchers, or commands of unknown duration without a timeout.",
2185
2849
  "Bundle closely related tiny observations into one babysit_run command when that reduces tool turns without obscuring lifecycle or failure handling.",
2186
2850
  "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.",
2187
2851
  "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').",
2188
2852
  "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.",
2189
2853
  "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 }.",
2190
2854
  "Delegate self-contained tasks (codebase recon, a parallelizable subtask, work that would pollute your context) with babysit_run { profile: \"subagent\", task }. Launch several for independent subtasks; they run concurrently.",
2855
+ "Set at least one subagent budget (`maxCost`, `maxTurns`, `maxToolCalls`, or `maxUsageTokens`) for bounded recon and review tasks. Omit budgets only for intentionally open-ended work; the absolute timeout remains a separate safety limit.",
2191
2856
  "Subagents cannot create further subagents by default (maximum depth 1). Only the top-level caller can explicitly opt in by setting maxDepth when it creates the first worker; nested workers inherit that limit and cannot raise it.",
2192
2857
  "After spawning subagents, do not idle-wait and do not end your turn to wait for them: keep making progress, then call babysit_wait (ids + mode any/all) when you need their results. Steer or send follow-up tasks with babysit_send; kill runaways with babysit_kill.",
2193
2858
  ],
@@ -2199,7 +2864,7 @@ export default function (pi: ExtensionAPI) {
2199
2864
  ),
2200
2865
  name: Type.Optional(
2201
2866
  Type.String({
2202
- description: "Friendly stable name for a process (becomes the session id), e.g. 'cargo-build'.",
2867
+ description: "Friendly stable name for a process or subagent (becomes the session id), e.g. 'cargo-build' or 'review-api'.",
2203
2868
  }),
2204
2869
  ),
2205
2870
  profile: Type.Optional(
@@ -2224,6 +2889,24 @@ export default function (pi: ExtensionAPI) {
2224
2889
  "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.",
2225
2890
  }),
2226
2891
  ),
2892
+ maxCost: Type.Optional(
2893
+ Type.Number({
2894
+ exclusiveMinimum: 0,
2895
+ description: "Subagent only: steer it to wrap up at this cumulative reported cost, then kill after the budget grace period.",
2896
+ }),
2897
+ ),
2898
+ maxTurns: Type.Optional(
2899
+ Type.Integer({ minimum: 1, description: "Subagent only: maximum turns per task." }),
2900
+ ),
2901
+ maxToolCalls: Type.Optional(
2902
+ Type.Integer({ minimum: 1, description: "Subagent only: maximum tool calls per task." }),
2903
+ ),
2904
+ maxUsageTokens: Type.Optional(
2905
+ Type.Integer({
2906
+ minimum: 1,
2907
+ description: "Subagent only: maximum cumulative reported totalTokens across model calls.",
2908
+ }),
2909
+ ),
2227
2910
  agentScope: Type.Optional(
2228
2911
  StringEnum(["user", "project", "both"] as const, {
2229
2912
  description: "Where to discover named agents. Default 'user'.",
@@ -2247,6 +2930,18 @@ export default function (pi: ExtensionAPI) {
2247
2930
  "Process mode: run in a PTY (default true; enables interactive input/screen). false = plain pipes for cleaner line-oriented logs.",
2248
2931
  }),
2249
2932
  ),
2933
+ foreground: Type.Optional(
2934
+ Type.Boolean({
2935
+ description:
2936
+ "Process mode: wait for exit and return the result in this tool call. Use when the next step needs the result; avoid for servers/watchers unless bounded by timeout.",
2937
+ }),
2938
+ ),
2939
+ notificationGroup: Type.Optional(
2940
+ Type.String({
2941
+ description:
2942
+ "Process mode: defer automatic completion until every running process with this group has stopped, then send one batched notification. Sibling background runs are auto-grouped when omitted.",
2943
+ }),
2944
+ ),
2250
2945
  continueAfterStart: Type.Optional(
2251
2946
  Type.Boolean({
2252
2947
  description:
@@ -2285,6 +2980,39 @@ export default function (pi: ExtensionAPI) {
2285
2980
  details: {},
2286
2981
  };
2287
2982
  }
2983
+ const hasBudget =
2984
+ params.maxCost != null ||
2985
+ params.maxTurns != null ||
2986
+ params.maxToolCalls != null ||
2987
+ params.maxUsageTokens != null;
2988
+ if (!isSubagent && hasBudget) {
2989
+ return {
2990
+ content: [{ type: "text", text: "Subagent budget parameters require profile 'subagent'." }],
2991
+ isError: true,
2992
+ details: {},
2993
+ };
2994
+ }
2995
+ if (isSubagent && params.notificationGroup) {
2996
+ return {
2997
+ content: [{ type: "text", text: "`notificationGroup` is available only in process mode." }],
2998
+ isError: true,
2999
+ details: {},
3000
+ };
3001
+ }
3002
+ if (isSubagent && params.foreground) {
3003
+ return {
3004
+ content: [{ type: "text", text: "`foreground` is available only in process mode; use babysit_wait for subagent task completion." }],
3005
+ isError: true,
3006
+ details: {},
3007
+ };
3008
+ }
3009
+ if (!isSubagent && params.foreground && params.continueAfterStart) {
3010
+ return {
3011
+ content: [{ type: "text", text: "`foreground` and `continueAfterStart` are mutually exclusive." }],
3012
+ isError: true,
3013
+ details: {},
3014
+ };
3015
+ }
2288
3016
 
2289
3017
  // Compute nesting only for subagent mode. Ordinary command processes remain
2290
3018
  // available even when the hosting agent is at its subagent depth limit.
@@ -2308,6 +3036,7 @@ export default function (pi: ExtensionAPI) {
2308
3036
  timeout: params.timeout,
2309
3037
  idleTimeout: params.idleTimeout,
2310
3038
  pty: params.pty ?? true,
3039
+ notificationGroup: params.notificationGroup?.trim() || undefined,
2311
3040
  };
2312
3041
  let res = await spawnProcess(spawnOpts);
2313
3042
  if ("error" in res) {
@@ -2320,24 +3049,27 @@ export default function (pi: ExtensionAPI) {
2320
3049
  pollNeeded = true;
2321
3050
  await refreshWidget(ctx);
2322
3051
 
2323
- // One-shot / non-interactive mode (e.g. `pi -p`): there is no live
2324
- // event loop to deliver the exit notification, and ending the turn
2325
- // would exit pi entirely — orphaning the babysat process and losing
2326
- // the session (the agent "settles" the moment it is told the turn will
2327
- // stop). So block inline like a normal command and return the full
2328
- // outcome in THIS turn. The process still runs under babysit (logged,
2329
- // killable), we just wait for it here instead of fire-and-forget.
2330
- if (!ctx.hasUI) {
2331
- let outcome = await waitForExit(res.id, parseDurMs(params.timeout), _signal);
3052
+ // One-shot / non-interactive mode has no event loop that can deliver an
3053
+ // exit notification. `foreground: true` provides the same single-tool-call
3054
+ // result in interactive mode, avoiding a separate babysit_wait model turn.
3055
+ // The command remains supervised, logged, killable, and subject to its
3056
+ // babysit timeout in either case.
3057
+ if (!ctx.hasUI || params.foreground) {
3058
+ // The babysit supervisor owns the absolute command timeout. Waiting with
3059
+ // the same deadline here races its terminal-state write and can return a
3060
+ // false "still running" result at the boundary, so wait for the
3061
+ // supervisor's definitive exit instead.
3062
+ let outcome = await waitForExit(res.id, null, _signal);
2332
3063
  let retried = false;
2333
3064
  if (params.retryOnWorkerDeath && outcome.status?.state === "dead" && outcome.status.exit_code == null) {
2334
3065
  const retry = await spawnProcess(spawnOpts);
2335
3066
  if (!("error" in retry)) {
2336
3067
  res = retry;
2337
3068
  retried = true;
2338
- outcome = await waitForExit(res.id, parseDurMs(params.timeout), _signal);
3069
+ outcome = await waitForExit(res.id, null, _signal);
2339
3070
  }
2340
3071
  }
3072
+ if (ctx.hasUI) await refreshWidget(ctx);
2341
3073
  return {
2342
3074
  content: [{ type: "text", text: `${retried ? "Retried once after external worker death.\n" : ""}${outcome.text}` }],
2343
3075
  isError: !outcome.ok,
@@ -2353,9 +3085,9 @@ export default function (pi: ExtensionAPI) {
2353
3085
  }
2354
3086
 
2355
3087
  // Keep ordinary quick commands ergonomic. Give the process a short grace
2356
- // period; if it exits, return only lifecycle metadata + log path now.
2357
- // A timeout means it is genuinely background work and follows the normal
2358
- // parked-turn / automatic-notification contract below.
3088
+ // period; if it exits, return lifecycle metadata + log path immediately.
3089
+ // A process still running after the grace follows the parked-turn /
3090
+ // automatic-notification contract below.
2359
3091
  await bs(["wait", "-s", res.id, "--timeout", QUICK_COMMAND_GRACE], { signal: _signal });
2360
3092
  let quickStatus = await statusOf(res.id);
2361
3093
  let retried = false;
@@ -2443,6 +3175,7 @@ export default function (pi: ExtensionAPI) {
2443
3175
  // The branch above guarantees a successful plan in subagent mode.
2444
3176
  const subagentNesting = nesting as Extract<SubagentSpawnPlan, { allowed: true }>;
2445
3177
  const res = await spawnSubagent({
3178
+ name: params.name,
2446
3179
  agent,
2447
3180
  task: params.task as string,
2448
3181
  model: params.model,
@@ -2450,6 +3183,15 @@ export default function (pi: ExtensionAPI) {
2450
3183
  cwd: ctx.cwd,
2451
3184
  depth: subagentNesting.childDepth,
2452
3185
  maxDepth: subagentNesting.maxDepth,
3186
+ budget:
3187
+ hasBudget
3188
+ ? {
3189
+ maxCost: params.maxCost,
3190
+ maxTurns: params.maxTurns,
3191
+ maxToolCalls: params.maxToolCalls,
3192
+ maxUsageTokens: params.maxUsageTokens,
3193
+ }
3194
+ : undefined,
2453
3195
  timeout: params.timeout ?? "15m",
2454
3196
  idleTimeout: params.idleTimeout,
2455
3197
  });
@@ -2479,6 +3221,7 @@ export default function (pi: ExtensionAPI) {
2479
3221
  details: {
2480
3222
  id: res.id,
2481
3223
  kind: "subagent",
3224
+ name: params.name ?? res.id,
2482
3225
  agent: agent?.name,
2483
3226
  model: res.model,
2484
3227
  task: params.task,
@@ -2538,14 +3281,25 @@ export default function (pi: ExtensionAPI) {
2538
3281
  name: "babysit_check",
2539
3282
  label: "Babysit: check",
2540
3283
  description:
2541
- "Inspect babysit session(s). Without an id: lists all sessions (processes + subagents). " +
2542
- "With an id: a process shows state + recent output, searches its log with `pattern`, " +
3284
+ "Inspect babysit session(s). Without an id: lists running sessions by default; use `state: \"all\"` " +
3285
+ "for history, or filter by terminal state/kind. With an id: a process shows state + recent " +
3286
+ "output, searches its log with `pattern`, " +
2543
3287
  "or captures the rendered screen with `screen: true`; a subagent shows live progress " +
2544
3288
  "(or raw log matches with `pattern`). Results are bounded by `lines` and clipped. " +
2545
3289
  "Do NOT poll this while merely waiting for a process to end — the exit notification is automatic.",
2546
3290
  promptSnippet: "Check status/progress of babysit sessions (processes and subagents)",
2547
3291
  parameters: Type.Object({
2548
- id: Type.Optional(Type.String({ description: "Session id. Omit to list all sessions." })),
3292
+ id: Type.Optional(Type.String({ description: "Session id. Omit to list sessions." })),
3293
+ state: Type.Optional(
3294
+ StringEnum(["running", "terminal", "all"] as const, {
3295
+ description: "List mode only: state filter. Defaults to running; terminal means any non-running state.",
3296
+ }),
3297
+ ),
3298
+ kind: Type.Optional(
3299
+ StringEnum(["process", "subagent", "all"] as const, {
3300
+ description: "List mode only: session kind filter. Defaults to all.",
3301
+ }),
3302
+ ),
2549
3303
  tools: Type.Optional(
2550
3304
  Type.Number({ description: "Subagent: how many recent tool calls to show (default 8, max 50)." }),
2551
3305
  ),
@@ -2577,9 +3331,38 @@ export default function (pi: ExtensionAPI) {
2577
3331
  };
2578
3332
  }
2579
3333
  if (sessions.length === 0) {
2580
- return { content: [{ type: "text", text: "No babysit sessions." }], details: {} };
3334
+ return { content: [{ type: "text", text: "No babysit sessions." }], details: { sessions: [] } };
3335
+ }
3336
+ const stateFilter = params.state ?? "running";
3337
+ const kindFilter = params.kind ?? "all";
3338
+ const stateMatches = (session: BsSession) => stateFilter === "all"
3339
+ ? true
3340
+ : stateFilter === "running"
3341
+ ? session.state === "running"
3342
+ : session.state !== "running";
3343
+ const kindOfSession = (session: BsSession) => readMeta(session.id)?.kind ?? "process";
3344
+ const selected = sessions.filter((session) =>
3345
+ stateMatches(session) && (kindFilter === "all" || kindOfSession(session) === kindFilter),
3346
+ );
3347
+ const reveal: string[] = [];
3348
+ if (stateFilter !== "all" && sessions.some((session) => !stateMatches(session))) {
3349
+ reveal.push('state: "all"');
3350
+ }
3351
+ if (kindFilter !== "all" && sessions.some((session) => kindOfSession(session) !== kindFilter)) {
3352
+ reveal.push('kind: "all"');
3353
+ }
3354
+ const revealHint = reveal.length > 0 ? `; use ${reveal.join(" and ")} to widen the list` : "";
3355
+ if (selected.length === 0) {
3356
+ const hidden = sessions.length;
3357
+ return {
3358
+ content: [{
3359
+ type: "text",
3360
+ text: `No ${stateFilter}${kindFilter === "all" ? "" : ` ${kindFilter}`} babysit sessions.${hidden > 0 ? ` ${hidden} session(s) hidden${revealHint}.` : ""}`,
3361
+ }],
3362
+ details: { sessions: [], total: sessions.length, hidden, state: stateFilter, kind: kindFilter },
3363
+ };
2581
3364
  }
2582
- const lines = sessions.map((s) => {
3365
+ const lines = selected.map((s) => {
2583
3366
  const meta = readMeta(s.id);
2584
3367
  const kind = meta?.kind ?? "process";
2585
3368
  const flag = s.note ? ` ⚑ ${s.note}` : "";
@@ -2592,7 +3375,12 @@ export default function (pi: ExtensionAPI) {
2592
3375
  const preview = what.length > 60 ? `${what.slice(0, 57)}…` : what;
2593
3376
  return `${s.id} [${kind}] ${s.state}${ec}${depth}${flag}${preview ? ` — ${preview}` : ""}`;
2594
3377
  });
2595
- return { content: [{ type: "text", text: clip(lines.join("\n")) }], details: { sessions } };
3378
+ const hidden = sessions.length - selected.length;
3379
+ const suffix = hidden > 0 ? `\n… ${hidden} session(s) hidden${revealHint}.` : "";
3380
+ return {
3381
+ content: [{ type: "text", text: clip(lines.join("\n") + suffix) }],
3382
+ details: { sessions: selected, total: sessions.length, hidden, state: stateFilter, kind: kindFilter },
3383
+ };
2596
3384
  }
2597
3385
 
2598
3386
  const st = await statusOf(params.id);
@@ -2685,9 +3473,9 @@ export default function (pi: ExtensionAPI) {
2685
3473
  : " · working";
2686
3474
  }
2687
3475
  if (st.exit_code != null) header += ` exit_code=${st.exit_code}`;
2688
- header += ` turns=${prog.turns} tools=${prog.toolCalls.length}`;
3476
+ header += ` turns=${prog.turns} calls=${prog.modelCalls} tools=${prog.toolCalls.length}`;
2689
3477
  if (prog.tokens != null) header += ` ctx=${prog.tokens}`;
2690
- if (prog.cost != null) header += ` $${prog.cost.toFixed(4)}`;
3478
+ if (prog.modelCalls > 0) header += ` usage=${prog.usageTokens} $${prog.cost.toFixed(4)}`;
2691
3479
  if (st.note) header += ` ⚑ ${st.note}`;
2692
3480
  parts.push(header);
2693
3481
 
@@ -2704,10 +3492,7 @@ export default function (pi: ExtensionAPI) {
2704
3492
  if (prog.finalText.trim()) {
2705
3493
  parts.push(`--- answer so far ---\n${clip(prog.finalText.trim(), ANSWER_MAX_BYTES)}`);
2706
3494
  } else if (prog.toolCalls.length === 0 && st.state !== "running") {
2707
- // Died before doing anything — show the log tail so the cause of
2708
- // death (model error, crash, timeout) is visible, not hidden.
2709
- const tail = clip((await bs(["log", "-s", params.id, "--tail", "15"])).stdout.trim());
2710
- parts.push(tail ? `--- last output ---\n${tail}` : "(no output)");
3495
+ parts.push(buildSubagentExitDiagnostic(prog, logPath(params.id)));
2711
3496
  } else if (prog.toolCalls.length === 0) {
2712
3497
  parts.push("(starting up… no events yet)");
2713
3498
  } else {
@@ -2810,6 +3595,21 @@ export default function (pi: ExtensionAPI) {
2810
3595
  }
2811
3596
 
2812
3597
  // --- subagent: steer / follow-up task over RPC ---
3598
+ return withSessionRpcLock(params.id, async () => {
3599
+ const lockedStatus = await statusOf(params.id);
3600
+ const meta = readMeta(params.id);
3601
+ if (lockedStatus?.state !== "running" || meta?.kind !== "subagent") {
3602
+ return {
3603
+ content: [
3604
+ {
3605
+ type: "text" as const,
3606
+ text: `Session ${params.id} is not a running subagent (${lockedStatus?.state ?? "missing"}).`,
3607
+ },
3608
+ ],
3609
+ isError: true,
3610
+ details: {},
3611
+ };
3612
+ }
2813
3613
  if (!params.text) {
2814
3614
  return {
2815
3615
  content: [{ type: "text", text: "Provide `text` (steering or follow-up task)." }],
@@ -2891,6 +3691,11 @@ export default function (pi: ExtensionAPI) {
2891
3691
  : undefined,
2892
3692
  depth: meta?.depth,
2893
3693
  maxDepth: meta?.maxDepth,
3694
+ budget: meta?.budget,
3695
+ // Each follow-up task receives a fresh budget window.
3696
+ budgetExceededAt: undefined,
3697
+ budgetReason: undefined,
3698
+ budgetKilled: undefined,
2894
3699
  });
2895
3700
  } else if (delivery.tempDir && meta) {
2896
3701
  writeMeta(params.id, {
@@ -2913,6 +3718,7 @@ export default function (pi: ExtensionAPI) {
2913
3718
  ],
2914
3719
  details: { mode, kind: "subagent" },
2915
3720
  };
3721
+ });
2916
3722
  },
2917
3723
  });
2918
3724
 
@@ -2926,6 +3732,8 @@ export default function (pi: ExtensionAPI) {
2926
3732
  "for 'listening on' before hitting a dev server). A subagent finishes when its current " +
2927
3733
  "TASK completes (the session stays alive for follow-ups). Pass `id` for one session, or " +
2928
3734
  "`ids` + `mode`: 'all' (default) waits for every one, 'any' returns on the FIRST finisher. " +
3735
+ "Multi-session results are capped at the inline-output limit (8 KB by default); use `maxBytes` " +
3736
+ "to opt into a larger result up to 24 KB. " +
2929
3737
  "Prefer ending your turn over babysit_wait when a process result is not needed this turn — " +
2930
3738
  "the exit notification will resume you.",
2931
3739
  promptSnippet: "Block until session(s) finish — process exit / output pattern / subagent task done",
@@ -2933,7 +3741,8 @@ export default function (pi: ExtensionAPI) {
2933
3741
  id: Type.Optional(Type.String({ description: "Session id (single wait)." })),
2934
3742
  ids: Type.Optional(
2935
3743
  Type.Array(Type.String(), {
2936
- description: "Session ids for a multi-wait (use with mode).",
3744
+ maxItems: MAX_MULTI_WAIT_SESSIONS,
3745
+ description: `Session ids for a multi-wait (use with mode; maximum ${MAX_MULTI_WAIT_SESSIONS}, no duplicates).`,
2937
3746
  }),
2938
3747
  ),
2939
3748
  mode: Type.Optional(
@@ -2950,6 +3759,14 @@ export default function (pi: ExtensionAPI) {
2950
3759
  "Process only: return as soon as this regex appears in the output (readiness marker) instead of waiting for exit.",
2951
3760
  }),
2952
3761
  ),
3762
+ maxBytes: Type.Optional(
3763
+ Type.Integer({
3764
+ minimum: 1_000,
3765
+ maximum: ANSWER_MAX_BYTES,
3766
+ description:
3767
+ "Multi-session result cap in bytes. Defaults to PI_BABYSIT_INLINE_OUTPUT_MAX_BYTES (8 KB).",
3768
+ }),
3769
+ ),
2953
3770
  }),
2954
3771
  async execute(_id, params, signal) {
2955
3772
  await requireBabysit();
@@ -2961,7 +3778,22 @@ export default function (pi: ExtensionAPI) {
2961
3778
  details: {},
2962
3779
  };
2963
3780
  }
3781
+ if (ids.length > MAX_MULTI_WAIT_SESSIONS) {
3782
+ return {
3783
+ content: [{ type: "text", text: `Multi-wait supports at most ${MAX_MULTI_WAIT_SESSIONS} sessions.` }],
3784
+ isError: true,
3785
+ details: {},
3786
+ };
3787
+ }
3788
+ if (new Set(ids).size !== ids.length) {
3789
+ return {
3790
+ content: [{ type: "text", text: "Multi-wait session ids must be unique." }],
3791
+ isError: true,
3792
+ details: {},
3793
+ };
3794
+ }
2964
3795
  const limitMs = parseDurMs(params.timeout);
3796
+ const multiResultMaxBytes = params.maxBytes ?? INLINE_OUTPUT_MAX_BYTES;
2965
3797
  if (params.timeout !== undefined && limitMs === null) {
2966
3798
  throw new Error(
2967
3799
  `Invalid timeout ${JSON.stringify(params.timeout)}; use an integer with ms, s, m, or h (for example "90s" or "5m").`,
@@ -2992,9 +3824,9 @@ export default function (pi: ExtensionAPI) {
2992
3824
  content: [
2993
3825
  {
2994
3826
  type: "text",
2995
- text: clip(
3827
+ text: clipMultiWaitResult(
2996
3828
  results.map((result) => `── ${result.id} [${result.kind}] ──\n${result.text}`).join("\n\n"),
2997
- ANSWER_MAX_BYTES,
3829
+ multiResultMaxBytes,
2998
3830
  ),
2999
3831
  },
3000
3832
  ],
@@ -3019,11 +3851,11 @@ export default function (pi: ExtensionAPI) {
3019
3851
  content: [
3020
3852
  {
3021
3853
  type: "text",
3022
- text: clip(
3854
+ text: clipMultiWaitResult(
3023
3855
  `First to finish: ${first.id} [${first.kind}]` +
3024
3856
  (others.length ? ` — still waiting-able: ${others.join(", ")}` : "") +
3025
3857
  `\n\n${first.text}`,
3026
- ANSWER_MAX_BYTES,
3858
+ multiResultMaxBytes,
3027
3859
  ),
3028
3860
  },
3029
3861
  ],
@@ -3095,8 +3927,46 @@ export default function (pi: ExtensionAPI) {
3095
3927
  // output + a copy-paste `babysit attach` take-over hint; running subagent →
3096
3928
  // read-only progress; finished → summary. Re-run to refresh.
3097
3929
  pi.registerCommand("babysit", {
3098
- description: "Pick a babysit session (↑/↓) to snapshot/inspect",
3099
- handler: async (_args, ctx) => {
3930
+ description: "Pick a session to inspect, or `/babysit gc [days]` to remove old terminal roots",
3931
+ handler: async (args, ctx) => {
3932
+ const command = args.trim();
3933
+ if (command === "gc" || command.startsWith("gc ")) {
3934
+ const daysText = command.slice(2).trim();
3935
+ const days = daysText ? Number(daysText) : 14;
3936
+ if (!Number.isFinite(days) || days <= 0) {
3937
+ ctx.ui.notify("Usage: /babysit gc [positive retention days]", "error");
3938
+ return;
3939
+ }
3940
+ const options = {
3941
+ rootBase: ROOT_BASE,
3942
+ currentRoot: ROOT,
3943
+ olderThanMs: days * 86_400_000,
3944
+ };
3945
+ const preview = gcBabysitRoots({ ...options, dryRun: true });
3946
+ if (preview.candidates.length === 0) {
3947
+ ctx.ui.notify(
3948
+ `No terminal pi-babysit roots older than ${days} days are safe to remove.`,
3949
+ "info",
3950
+ );
3951
+ return;
3952
+ }
3953
+ const confirmed = await ctx.ui.confirm(
3954
+ "Remove old pi-babysit roots?",
3955
+ `${preview.candidates.length} roots · ${preview.bytes} bytes · older than ${days} days\n` +
3956
+ "Running supervisors and the current Pi session are excluded.",
3957
+ );
3958
+ if (!confirmed) return;
3959
+ const removed = gcBabysitRoots({ ...options, dryRun: false });
3960
+ ctx.ui.notify(
3961
+ `Removed ${removed.deleted.length} pi-babysit roots (${removed.bytes} bytes).`,
3962
+ "info",
3963
+ );
3964
+ return;
3965
+ }
3966
+ if (command) {
3967
+ ctx.ui.notify("Usage: /babysit or /babysit gc [days]", "error");
3968
+ return;
3969
+ }
3100
3970
  if (!(await babysitAvailable())) {
3101
3971
  ctx.ui.notify(babysitPreflightError ?? INSTALL_HINT, "error");
3102
3972
  return;
@@ -3140,14 +4010,13 @@ export default function (pi: ExtensionAPI) {
3140
4010
  // Parse the RPC event stream and show the final answer, not raw JSONL.
3141
4011
  const prog = taskProgressOf(picked.id).progress;
3142
4012
  const stats =
3143
- `turns=${prog.turns} tools=${prog.toolCalls.length}` +
4013
+ `turns=${prog.turns} calls=${prog.modelCalls} tools=${prog.toolCalls.length}` +
3144
4014
  (prog.tokens != null ? ` ctx=${prog.tokens}` : "") +
3145
- (prog.cost != null ? ` $${prog.cost.toFixed(4)}` : "");
4015
+ (prog.modelCalls > 0 ? ` usage=${prog.usageTokens} $${prog.cost.toFixed(4)}` : "");
3146
4016
  const body =
3147
4017
  (prog.finalText.trim() ||
3148
4018
  prog.errorMsg ||
3149
- (await bs(["log", "-s", picked.id, "--tail", "20"])).stdout.trim() ||
3150
- "(no output)") +
4019
+ `(no structured output; full log: ${logPath(picked.id)})`) +
3151
4020
  (picked.state === "running"
3152
4021
  ? "\n\n_Live subagent (read-only). Re-run `/babysit` to refresh this snapshot._"
3153
4022
  : "");