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