@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/README.md +33 -17
- package/index.ts +748 -65
- package/package.json +1 -1
- package/rpc-stream-proxy.mjs +70 -10
- package/self-reap.ts +23 -10
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
|
|
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
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
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:
|
|
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
|
-
|
|
964
|
-
|
|
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
|
-
|
|
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
|
-
| {
|
|
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.
|
|
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 =
|
|
1097
|
-
event.
|
|
1098
|
-
|
|
1099
|
-
|
|
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 =
|
|
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
|
|
1347
|
-
//
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
1590
|
-
|
|
1591
|
-
const
|
|
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
|
|
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
|
|
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.
|
|
1942
|
-
// immediately before the single send so
|
|
1943
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
3641
|
+
text: clipMultiWaitResult(
|
|
2996
3642
|
results.map((result) => `── ${result.id} [${result.kind}] ──\n${result.text}`).join("\n\n"),
|
|
2997
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
3099
|
-
handler: async (
|
|
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.
|
|
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
|
-
(
|
|
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
|
: "");
|