@yusukeshib/pi-babysit 0.3.13 → 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
@@ -301,17 +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> {
304
+ async function lookupStatus(
305
+ id: string,
306
+ ): Promise<{ session: BsSession | null; error?: string }> {
305
307
  // `list --json` already carries every lifecycle field used by the extension,
306
308
  // including `alive` and `note`. Using it directly avoids the old
307
309
  // status-then-list pair (two CLI subprocesses for every status lookup).
308
310
  try {
309
- return (await listSessions()).sessions.find((session) => session.id === id) ?? null;
310
- } catch {
311
- 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) };
312
316
  }
313
317
  }
314
318
 
319
+ async function statusOf(id: string): Promise<BsSession | null> {
320
+ return (await lookupStatus(id)).session;
321
+ }
322
+
315
323
  export function isConfirmedTerminalState(state: string): boolean {
316
324
  return state === "killed" || state === "exited";
317
325
  }
@@ -347,11 +355,19 @@ async function awaitConfirmedTermination(id: string): Promise<BsSession | null>
347
355
  // kind=process: name/command + `notified` (exit notification dedup).
348
356
  // kind=subagent: task + the raw-log byte offset of the last prompt, which lets
349
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
+
350
365
  interface Meta {
351
366
  kind: "process" | "subagent";
352
367
  // process
353
368
  name?: string;
354
369
  command?: string;
370
+ notificationGroup?: string;
355
371
  notified?: boolean;
356
372
  // A confirmed kill permanently owns completion delivery. An interrupted
357
373
  // concurrent wait must not re-enable the automatic notification afterward.
@@ -373,6 +389,10 @@ interface Meta {
373
389
  messageTempDirs?: Array<{ dir: string; afterAgentEnd: number }>;
374
390
  depth?: number;
375
391
  maxDepth?: number;
392
+ budget?: SubagentBudget;
393
+ budgetExceededAt?: number;
394
+ budgetReason?: string;
395
+ budgetKilled?: boolean;
376
396
  }
377
397
 
378
398
  const metaDir = () => path.join(ROOT, "meta");
@@ -395,6 +415,228 @@ function readMeta(id: string): Meta | null {
395
415
  }
396
416
  }
397
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
+
398
640
  function cleanupMessageTempDirs(id: string, meta: Meta, agentEnds: number): void {
399
641
  if (!meta.messageTempDirs?.length) return;
400
642
  const keep: NonNullable<Meta["messageTempDirs"]> = [];
@@ -420,6 +662,19 @@ export function shouldDeliverProcessCompletion(
420
662
  return meta?.kind === "process" && !meta.notified && !meta.notificationPaused;
421
663
  }
422
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
+
423
678
  export function shouldDeferCompletionNotification(agentIsIdle: boolean): boolean {
424
679
  return !agentIsIdle;
425
680
  }
@@ -537,12 +792,19 @@ async function rpcResponse(
537
792
  if (e.code !== 0) {
538
793
  const st = await statusOf(id);
539
794
  if (st && st.state !== "running") {
540
- 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
+ }
541
802
  return {
542
803
  ok: false,
543
804
  error:
544
805
  `subagent exited (exit_code=${st.exit_code ?? "?"}) before responding to ${command}.` +
545
- (tail ? `\nLast output:\n${tail}` : ""),
806
+ (structuredError ? `\n${structuredError}` : "") +
807
+ `\nFull log: ${logPath(id)}`,
546
808
  };
547
809
  }
548
810
  return {
@@ -606,7 +868,10 @@ const INLINE_OUTPUT_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_INLINE_OUTPUT_MAX_B
606
868
  const NOTIFY_OUTPUT_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_OUTPUT_MAX_BYTES", 2_000);
607
869
  const NOTIFY_COMMAND_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_COMMAND_MAX_BYTES", 240);
608
870
  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
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;
610
875
 
611
876
  export function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
612
877
  if (maxBytes <= 0) return "";
@@ -630,6 +895,13 @@ export function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
630
895
  return `${head}${marker}${tail}`;
631
896
  }
632
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
+
633
905
  async function searchLog(
634
906
  id: string,
635
907
  pattern: string,
@@ -948,7 +1220,14 @@ export function deliverProcessCompletionMessage(
948
1220
  // for a subagent is a guidance violation, and treating it as completion keeps
949
1221
  // the parent from hanging forever.) `process` is the legacy pi-processes tool.
950
1222
  function isParkedMessages(
951
- messages: { role?: string; toolName?: string; content?: unknown }[] | undefined,
1223
+ messages:
1224
+ | {
1225
+ role?: string;
1226
+ toolName?: string;
1227
+ content?: unknown;
1228
+ details?: { kind?: string; status?: string };
1229
+ }[]
1230
+ | undefined,
952
1231
  ): boolean {
953
1232
  if (!messages) return false;
954
1233
  // Models sometimes add a short assistant note after starting a process. Scan
@@ -960,12 +1239,20 @@ function isParkedMessages(
960
1239
  if (message?.role !== "toolResult") continue;
961
1240
  if (message.toolName === "process") return true; // legacy pi-processes
962
1241
  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
1242
+ if (message.details?.kind === "process" && message.details.status === "started") {
1243
+ return true;
968
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;
969
1256
  }
970
1257
  return false;
971
1258
  }
@@ -982,8 +1269,17 @@ export interface Progress {
982
1269
  turns: number;
983
1270
  toolCalls: ToolCall[];
984
1271
  finalText: string;
1272
+ /** Context size reported by the most recent assistant response. */
985
1273
  tokens?: number;
986
- 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;
987
1283
  errorMsg?: string;
988
1284
  // RPC lifecycle bookkeeping (computed over the analyzed log slice):
989
1285
  agentStarts: number;
@@ -1027,6 +1323,14 @@ function emptyProgress(): Progress {
1027
1323
  turns: 0,
1028
1324
  toolCalls: [],
1029
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,
1030
1334
  agentStarts: 0,
1031
1335
  agentEnds: 0,
1032
1336
  agentSettled: 0,
@@ -1037,6 +1341,41 @@ function emptyProgress(): Progress {
1037
1341
  };
1038
1342
  }
1039
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
+
1040
1379
  function updateProgressState(progress: Progress): Progress {
1041
1380
  progress.running = progress.agentStarts > progress.agentEnds;
1042
1381
  progress.waitingOnProcess =
@@ -1073,7 +1412,19 @@ function parseEventLine(progress: Progress, raw: string): void {
1073
1412
  }
1074
1413
  case "message_end": {
1075
1414
  const message = event.message as
1076
- | { role?: string; content?: { type: string; text?: string }[]; usage?: { totalTokens?: number; cost?: { total?: number } } }
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
+ };
1427
+ }
1077
1428
  | undefined;
1078
1429
  if (message?.role === "assistant") {
1079
1430
  const text = (message.content ?? [])
@@ -1082,8 +1433,17 @@ function parseEventLine(progress: Progress, raw: string): void {
1082
1433
  .join("");
1083
1434
  if (text.trim()) progress.finalText = text;
1084
1435
  if (message.usage) {
1436
+ const finite = (value: number | undefined) =>
1437
+ typeof value === "number" && Number.isFinite(value) ? value : 0;
1438
+ progress.modelCalls++;
1085
1439
  progress.tokens = message.usage.totalTokens;
1086
- progress.cost = message.usage.cost?.total;
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);
1087
1447
  }
1088
1448
  }
1089
1449
  break;
@@ -1093,11 +1453,19 @@ function parseEventLine(progress: Progress, raw: string): void {
1093
1453
  break;
1094
1454
  case "agent_end": {
1095
1455
  progress.agentEnds++;
1096
- progress.lastEndWasProcessWait = isParkedMessages(
1097
- event.messages as
1098
- | { role?: string; toolName?: string; content?: unknown }[]
1099
- | undefined,
1100
- );
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
+ );
1101
1469
  break;
1102
1470
  }
1103
1471
  case "agent_settled":
@@ -1111,6 +1479,11 @@ function parseEventLine(progress: Progress, raw: string): void {
1111
1479
  case "error":
1112
1480
  progress.errorMsg = String(event.message ?? event.error ?? line);
1113
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;
1486
+ }
1114
1487
  }
1115
1488
  }
1116
1489
 
@@ -1120,6 +1493,35 @@ export function parseEvents(logText: string): Progress {
1120
1493
  return updateProgressState(progress);
1121
1494
  }
1122
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
+
1123
1525
  interface TaskProgressCache {
1124
1526
  base: number;
1125
1527
  offset: number;
@@ -1164,8 +1566,12 @@ function taskProgressOf(id: string): { progress: Progress; offset: number } {
1164
1566
  cached.pending = Buffer.from(bytes.subarray(start));
1165
1567
  }
1166
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
+ }
1167
1573
  if (cached.progress.agentEnds > 0) {
1168
- const meta = readMeta(id);
1574
+ const meta = currentMeta;
1169
1575
  if (meta) cleanupMessageTempDirs(id, meta, cached.progress.agentEnds);
1170
1576
  }
1171
1577
  // If the writer was observed mid-record, let `babysit expect --since` start
@@ -1197,6 +1603,7 @@ interface ProcOpts {
1197
1603
  timeout?: string; // default: none — dev servers may run indefinitely
1198
1604
  idleTimeout?: string;
1199
1605
  pty: boolean;
1606
+ notificationGroup?: string;
1200
1607
  }
1201
1608
 
1202
1609
  async function spawnProcess(opts: ProcOpts): Promise<{ id: string } | { error: string }> {
@@ -1226,6 +1633,7 @@ async function spawnProcess(opts: ProcOpts): Promise<{ id: string } | { error: s
1226
1633
  kind: "process",
1227
1634
  name: opts.name ?? id,
1228
1635
  command: opts.command,
1636
+ notificationGroup: opts.notificationGroup,
1229
1637
  notified: false,
1230
1638
  startedAt: Date.now(),
1231
1639
  });
@@ -1286,6 +1694,7 @@ interface SubagentOpts {
1286
1694
  cwd: string;
1287
1695
  depth: number;
1288
1696
  maxDepth: number;
1697
+ budget?: SubagentBudget;
1289
1698
  // Idle-timeout is OFF by default: an RPC-mode pi is silent while it works,
1290
1699
  // so idle detection would false-kill a busy subagent. The absolute timeout
1291
1700
  // is the safety valve instead.
@@ -1343,8 +1752,8 @@ async function spawnSubagent(
1343
1752
  opts.timeout,
1344
1753
  ];
1345
1754
  // 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.
1755
+ // proxy retains authoritative message_end/response/error events and reduces
1756
+ // redundant lifecycle/tool payloads; parseEvents accepts both log formats.
1348
1757
  if (VIEW_CMD.trim()) bsArgs.push("--view-cmd", VIEW_CMD);
1349
1758
  if (opts.idleTimeout && opts.idleTimeout !== "none") {
1350
1759
  bsArgs.push("--idle-timeout", opts.idleTimeout);
@@ -1387,6 +1796,7 @@ async function spawnSubagent(
1387
1796
  notified: true,
1388
1797
  depth: opts.depth,
1389
1798
  maxDepth: opts.maxDepth,
1799
+ budget: opts.budget,
1390
1800
  });
1391
1801
 
1392
1802
  // Wait for pi to boot (first JSON event in the log), then inject the task.
@@ -1450,6 +1860,7 @@ async function spawnSubagent(
1450
1860
  : undefined,
1451
1861
  depth: opts.depth,
1452
1862
  maxDepth: opts.maxDepth,
1863
+ budget: opts.budget,
1453
1864
  startedAt: Date.now(),
1454
1865
  });
1455
1866
  return { id, model: resolvedModel };
@@ -1562,43 +1973,39 @@ async function waitForTask(
1562
1973
  const st = await statusOf(id);
1563
1974
 
1564
1975
  const stats =
1565
- `turns=${prog.turns} tools=${prog.toolCalls.length}` +
1976
+ `turns=${prog.turns} calls=${prog.modelCalls} tools=${prog.toolCalls.length}` +
1566
1977
  (prog.tokens != null ? ` ctx=${prog.tokens}` : "") +
1567
- (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
+ : "");
1568
1981
 
1569
1982
  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;
1983
+ const completed = buildSubagentDoneResult(prog);
1575
1984
  return {
1576
1985
  id,
1577
1986
  kind: "done",
1578
- ok,
1987
+ ok: completed.ok,
1579
1988
  text:
1580
1989
  `Subagent ${id} finished its task (${stats}).\n` +
1581
1990
  `Session stays alive — follow-up: babysit_send { id: "${id}" }, ` +
1582
- `or babysit_kill when done.\n\n${body}`,
1991
+ `or babysit_kill when done.\n\n${completed.body}`,
1583
1992
  status: st,
1584
1993
  progress: prog,
1585
1994
  };
1586
1995
  }
1587
1996
 
1588
1997
  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
- );
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));
1595
2001
  return {
1596
2002
  id,
1597
2003
  kind: "exited",
1598
2004
  ok: false,
1599
2005
  text:
1600
2006
  `Subagent ${id} EXITED before completing the task ` +
1601
- `(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,
1602
2009
  status: st,
1603
2010
  progress: prog,
1604
2011
  };
@@ -1777,12 +2184,20 @@ async function waitForExit(
1777
2184
  }
1778
2185
  }
1779
2186
 
1780
- 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;
1781
2200
  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
2201
  if (!expectPattern) updateWaitReservation(id, "claim");
1787
2202
  return { id, kind: "exited", ok: false, text: `No such session: ${id}` };
1788
2203
  }
@@ -1850,7 +2265,27 @@ export function activeToolsWithoutDirectBash(activeTools: string[], allowDirectB
1850
2265
  export default function (pi: ExtensionAPI) {
1851
2266
  let pollTimer: ReturnType<typeof setInterval> | undefined;
1852
2267
  let pollNeeded = true;
2268
+ let automaticGcRan = false;
2269
+ let rootLeasePath: string | undefined;
1853
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
+ }
1854
2289
 
1855
2290
  // Pi only persists custom-tool failures when they are thrown or patched by a
1856
2291
  // tool_result hook; an `isError` property returned from execute() is ignored.
@@ -1876,6 +2311,81 @@ export default function (pi: ExtensionAPI) {
1876
2311
  if (declaredToolErrors.delete(event.toolCallId)) return { isError: true };
1877
2312
  });
1878
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
+
1879
2389
  // Exit notifications for kind=process sessions: the poller detects
1880
2390
  // running→exited transitions and injects ONE message (triggerTurn) for all
1881
2391
  // processes that became deliverable in the same poll. This resumes an agent
@@ -1896,6 +2406,9 @@ export default function (pi: ExtensionAPI) {
1896
2406
  if (session.state === "running") continue;
1897
2407
  const meta = readMeta(session.id);
1898
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;
1899
2412
  // Delay delivery by one poll interval. This gives an agent that chose
1900
2413
  // babysit_wait immediately after babysit_run enough time to claim the
1901
2414
  // completion and suppress the otherwise duplicate automatic message.
@@ -1938,13 +2451,17 @@ export default function (pi: ExtensionAPI) {
1938
2451
  });
1939
2452
  }
1940
2453
 
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.
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;
1944
2460
  const metadataById = new Map<string, Meta>();
1945
2461
  const notices = prepared.flatMap((notice) => {
1946
2462
  const current = readMeta(notice.id);
1947
2463
  if (!shouldDeliverProcessCompletion(current)) return [];
2464
+ if (!isNotificationGroupReady(current, finalSessions, readMeta)) return [];
1948
2465
  metadataById.set(notice.id, current);
1949
2466
  return [{ ...notice, command: current.command }];
1950
2467
  });
@@ -2092,13 +2609,42 @@ export default function (pi: ExtensionAPI) {
2092
2609
  // Session-local registry: scope the babysit root to this pi session so
2093
2610
  // other sessions' processes/subagents are invisible here. Resuming a
2094
2611
  // session keeps the same id, so its sessions come back with it.
2612
+ releaseRootLease(rootLeasePath);
2095
2613
  try {
2096
2614
  ROOT = path.join(ROOT_BASE, ctx.sessionManager.getSessionId());
2097
2615
  } catch {
2098
2616
  ROOT = ROOT_BASE;
2099
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
+ }
2100
2630
  taskProgressCache.clear();
2101
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
+ }
2102
2648
  // Warn early if the binary is missing so the user isn't surprised only when
2103
2649
  // a tool later fails. Tools/commands still enforce it via requireBabysit.
2104
2650
  if (ctx.hasUI && !(await babysitAvailable())) {
@@ -2118,6 +2664,7 @@ export default function (pi: ExtensionAPI) {
2118
2664
  throw new Error(listed.error);
2119
2665
  }
2120
2666
  const snapshot = listed.sessions;
2667
+ await enforceSubagentBudgets(snapshot);
2121
2668
  await Promise.all([
2122
2669
  notifyEndedProcesses(ctx, snapshot),
2123
2670
  refreshWidget(ctx, snapshot),
@@ -2137,6 +2684,8 @@ export default function (pi: ExtensionAPI) {
2137
2684
  pi.on("session_shutdown", async () => {
2138
2685
  if (pollTimer) clearInterval(pollTimer);
2139
2686
  pollTimer = undefined;
2687
+ releaseRootLease(rootLeasePath);
2688
+ rootLeasePath = undefined;
2140
2689
  });
2141
2690
 
2142
2691
  pi.on("tool_call", async (event) => {
@@ -2224,6 +2773,24 @@ export default function (pi: ExtensionAPI) {
2224
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.",
2225
2774
  }),
2226
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
+ ),
2227
2794
  agentScope: Type.Optional(
2228
2795
  StringEnum(["user", "project", "both"] as const, {
2229
2796
  description: "Where to discover named agents. Default 'user'.",
@@ -2247,6 +2814,12 @@ export default function (pi: ExtensionAPI) {
2247
2814
  "Process mode: run in a PTY (default true; enables interactive input/screen). false = plain pipes for cleaner line-oriented logs.",
2248
2815
  }),
2249
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
+ ),
2250
2823
  continueAfterStart: Type.Optional(
2251
2824
  Type.Boolean({
2252
2825
  description:
@@ -2285,6 +2858,25 @@ export default function (pi: ExtensionAPI) {
2285
2858
  details: {},
2286
2859
  };
2287
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
+ }
2288
2880
 
2289
2881
  // Compute nesting only for subagent mode. Ordinary command processes remain
2290
2882
  // available even when the hosting agent is at its subagent depth limit.
@@ -2308,6 +2900,7 @@ export default function (pi: ExtensionAPI) {
2308
2900
  timeout: params.timeout,
2309
2901
  idleTimeout: params.idleTimeout,
2310
2902
  pty: params.pty ?? true,
2903
+ notificationGroup: params.notificationGroup?.trim() || undefined,
2311
2904
  };
2312
2905
  let res = await spawnProcess(spawnOpts);
2313
2906
  if ("error" in res) {
@@ -2450,6 +3043,15 @@ export default function (pi: ExtensionAPI) {
2450
3043
  cwd: ctx.cwd,
2451
3044
  depth: subagentNesting.childDepth,
2452
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,
2453
3055
  timeout: params.timeout ?? "15m",
2454
3056
  idleTimeout: params.idleTimeout,
2455
3057
  });
@@ -2685,9 +3287,9 @@ export default function (pi: ExtensionAPI) {
2685
3287
  : " · working";
2686
3288
  }
2687
3289
  if (st.exit_code != null) header += ` exit_code=${st.exit_code}`;
2688
- header += ` turns=${prog.turns} tools=${prog.toolCalls.length}`;
3290
+ header += ` turns=${prog.turns} calls=${prog.modelCalls} tools=${prog.toolCalls.length}`;
2689
3291
  if (prog.tokens != null) header += ` ctx=${prog.tokens}`;
2690
- if (prog.cost != null) header += ` $${prog.cost.toFixed(4)}`;
3292
+ if (prog.modelCalls > 0) header += ` usage=${prog.usageTokens} $${prog.cost.toFixed(4)}`;
2691
3293
  if (st.note) header += ` ⚑ ${st.note}`;
2692
3294
  parts.push(header);
2693
3295
 
@@ -2704,10 +3306,7 @@ export default function (pi: ExtensionAPI) {
2704
3306
  if (prog.finalText.trim()) {
2705
3307
  parts.push(`--- answer so far ---\n${clip(prog.finalText.trim(), ANSWER_MAX_BYTES)}`);
2706
3308
  } 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)");
3309
+ parts.push(buildSubagentExitDiagnostic(prog, logPath(params.id)));
2711
3310
  } else if (prog.toolCalls.length === 0) {
2712
3311
  parts.push("(starting up… no events yet)");
2713
3312
  } else {
@@ -2810,6 +3409,21 @@ export default function (pi: ExtensionAPI) {
2810
3409
  }
2811
3410
 
2812
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
+ }
2813
3427
  if (!params.text) {
2814
3428
  return {
2815
3429
  content: [{ type: "text", text: "Provide `text` (steering or follow-up task)." }],
@@ -2891,6 +3505,11 @@ export default function (pi: ExtensionAPI) {
2891
3505
  : undefined,
2892
3506
  depth: meta?.depth,
2893
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,
2894
3513
  });
2895
3514
  } else if (delivery.tempDir && meta) {
2896
3515
  writeMeta(params.id, {
@@ -2913,6 +3532,7 @@ export default function (pi: ExtensionAPI) {
2913
3532
  ],
2914
3533
  details: { mode, kind: "subagent" },
2915
3534
  };
3535
+ });
2916
3536
  },
2917
3537
  });
2918
3538
 
@@ -2926,6 +3546,8 @@ export default function (pi: ExtensionAPI) {
2926
3546
  "for 'listening on' before hitting a dev server). A subagent finishes when its current " +
2927
3547
  "TASK completes (the session stays alive for follow-ups). Pass `id` for one session, or " +
2928
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. " +
2929
3551
  "Prefer ending your turn over babysit_wait when a process result is not needed this turn — " +
2930
3552
  "the exit notification will resume you.",
2931
3553
  promptSnippet: "Block until session(s) finish — process exit / output pattern / subagent task done",
@@ -2933,7 +3555,8 @@ export default function (pi: ExtensionAPI) {
2933
3555
  id: Type.Optional(Type.String({ description: "Session id (single wait)." })),
2934
3556
  ids: Type.Optional(
2935
3557
  Type.Array(Type.String(), {
2936
- 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).`,
2937
3560
  }),
2938
3561
  ),
2939
3562
  mode: Type.Optional(
@@ -2950,6 +3573,14 @@ export default function (pi: ExtensionAPI) {
2950
3573
  "Process only: return as soon as this regex appears in the output (readiness marker) instead of waiting for exit.",
2951
3574
  }),
2952
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
+ ),
2953
3584
  }),
2954
3585
  async execute(_id, params, signal) {
2955
3586
  await requireBabysit();
@@ -2961,7 +3592,22 @@ export default function (pi: ExtensionAPI) {
2961
3592
  details: {},
2962
3593
  };
2963
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
+ }
2964
3609
  const limitMs = parseDurMs(params.timeout);
3610
+ const multiResultMaxBytes = params.maxBytes ?? INLINE_OUTPUT_MAX_BYTES;
2965
3611
  if (params.timeout !== undefined && limitMs === null) {
2966
3612
  throw new Error(
2967
3613
  `Invalid timeout ${JSON.stringify(params.timeout)}; use an integer with ms, s, m, or h (for example "90s" or "5m").`,
@@ -2992,9 +3638,9 @@ export default function (pi: ExtensionAPI) {
2992
3638
  content: [
2993
3639
  {
2994
3640
  type: "text",
2995
- text: clip(
3641
+ text: clipMultiWaitResult(
2996
3642
  results.map((result) => `── ${result.id} [${result.kind}] ──\n${result.text}`).join("\n\n"),
2997
- ANSWER_MAX_BYTES,
3643
+ multiResultMaxBytes,
2998
3644
  ),
2999
3645
  },
3000
3646
  ],
@@ -3019,11 +3665,11 @@ export default function (pi: ExtensionAPI) {
3019
3665
  content: [
3020
3666
  {
3021
3667
  type: "text",
3022
- text: clip(
3668
+ text: clipMultiWaitResult(
3023
3669
  `First to finish: ${first.id} [${first.kind}]` +
3024
3670
  (others.length ? ` — still waiting-able: ${others.join(", ")}` : "") +
3025
3671
  `\n\n${first.text}`,
3026
- ANSWER_MAX_BYTES,
3672
+ multiResultMaxBytes,
3027
3673
  ),
3028
3674
  },
3029
3675
  ],
@@ -3095,8 +3741,46 @@ export default function (pi: ExtensionAPI) {
3095
3741
  // output + a copy-paste `babysit attach` take-over hint; running subagent →
3096
3742
  // read-only progress; finished → summary. Re-run to refresh.
3097
3743
  pi.registerCommand("babysit", {
3098
- description: "Pick a babysit session (↑/↓) to snapshot/inspect",
3099
- 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
+ }
3100
3784
  if (!(await babysitAvailable())) {
3101
3785
  ctx.ui.notify(babysitPreflightError ?? INSTALL_HINT, "error");
3102
3786
  return;
@@ -3140,14 +3824,13 @@ export default function (pi: ExtensionAPI) {
3140
3824
  // Parse the RPC event stream and show the final answer, not raw JSONL.
3141
3825
  const prog = taskProgressOf(picked.id).progress;
3142
3826
  const stats =
3143
- `turns=${prog.turns} tools=${prog.toolCalls.length}` +
3827
+ `turns=${prog.turns} calls=${prog.modelCalls} tools=${prog.toolCalls.length}` +
3144
3828
  (prog.tokens != null ? ` ctx=${prog.tokens}` : "") +
3145
- (prog.cost != null ? ` $${prog.cost.toFixed(4)}` : "");
3829
+ (prog.modelCalls > 0 ? ` usage=${prog.usageTokens} $${prog.cost.toFixed(4)}` : "");
3146
3830
  const body =
3147
3831
  (prog.finalText.trim() ||
3148
3832
  prog.errorMsg ||
3149
- (await bs(["log", "-s", picked.id, "--tail", "20"])).stdout.trim() ||
3150
- "(no output)") +
3833
+ `(no structured output; full log: ${logPath(picked.id)})`) +
3151
3834
  (picked.state === "running"
3152
3835
  ? "\n\n_Live subagent (read-only). Re-run `/babysit` to refresh this snapshot._"
3153
3836
  : "");