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