@yusukeshib/pi-babysit 0.3.11 → 0.3.13
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 +28 -17
- package/agents.ts +23 -9
- package/index.ts +610 -225
- package/package.json +1 -1
- package/self-reap.ts +30 -23
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
|
*
|
|
@@ -36,10 +36,15 @@ import * as fs from "node:fs";
|
|
|
36
36
|
import * as os from "node:os";
|
|
37
37
|
import * as path from "node:path";
|
|
38
38
|
import { fileURLToPath } from "node:url";
|
|
39
|
-
import type {
|
|
39
|
+
import type {
|
|
40
|
+
ExtensionAPI,
|
|
41
|
+
ExtensionContext,
|
|
42
|
+
Theme,
|
|
43
|
+
ToolDefinition,
|
|
44
|
+
} from "@earendil-works/pi-coding-agent";
|
|
40
45
|
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
41
46
|
import { Box, Markdown, Text } from "@earendil-works/pi-tui";
|
|
42
|
-
import { Type } from "typebox";
|
|
47
|
+
import { Type, type TSchema } from "typebox";
|
|
43
48
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
44
49
|
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents";
|
|
45
50
|
|
|
@@ -127,7 +132,7 @@ export function planSubagentSpawn(
|
|
|
127
132
|
|
|
128
133
|
// Marker embedded in babysit_run's tool RESULT text for kind=process runs.
|
|
129
134
|
// It is how "the turn parked awaiting a process-exit notification" is told
|
|
130
|
-
// apart from any other turn end (see
|
|
135
|
+
// apart from any other turn end (see isParkedMessages / self-reap.ts).
|
|
131
136
|
export const NOTIFY_MARKER = "[notify-on-exit]";
|
|
132
137
|
|
|
133
138
|
// Human-readable view for the compact subagent JSONL stream when a human
|
|
@@ -297,22 +302,11 @@ async function listSessions(): Promise<{ sessions: BsSession[]; error?: string }
|
|
|
297
302
|
}
|
|
298
303
|
|
|
299
304
|
async function statusOf(id: string): Promise<BsSession | null> {
|
|
300
|
-
// `
|
|
301
|
-
// `
|
|
302
|
-
|
|
303
|
-
if (r.code !== 0) return null;
|
|
305
|
+
// `list --json` already carries every lifecycle field used by the extension,
|
|
306
|
+
// including `alive` and `note`. Using it directly avoids the old
|
|
307
|
+
// status-then-list pair (two CLI subprocesses for every status lookup).
|
|
304
308
|
try {
|
|
305
|
-
|
|
306
|
-
const inner = parsed.status ?? parsed;
|
|
307
|
-
// `note` and `alive` live only in `list --json`, so fold them in from there
|
|
308
|
-
// (alive is what lets us detect a crashed-but-"running" worker).
|
|
309
|
-
const listed = (await listSessions()).sessions.find((s) => s.id === id);
|
|
310
|
-
return normalizeSession({
|
|
311
|
-
id: parsed.session ?? id,
|
|
312
|
-
note: listed?.note ?? null,
|
|
313
|
-
alive: listed?.alive,
|
|
314
|
-
...inner,
|
|
315
|
-
});
|
|
309
|
+
return (await listSessions()).sessions.find((session) => session.id === id) ?? null;
|
|
316
310
|
} catch {
|
|
317
311
|
return null;
|
|
318
312
|
}
|
|
@@ -365,12 +359,18 @@ interface Meta {
|
|
|
365
359
|
// Temporary reservation while kill is in flight. Unlike `notified`, this
|
|
366
360
|
// must be cleared on failure so a real completion remains deliverable.
|
|
367
361
|
notificationPaused?: boolean;
|
|
362
|
+
// Concurrent explicit waits share a reference-counted notification claim.
|
|
363
|
+
// A timeout must not re-enable the poller while another wait still owns it.
|
|
364
|
+
waitReservations?: number;
|
|
365
|
+
waitCompletionClaimed?: boolean;
|
|
368
366
|
completionObservedAt?: number;
|
|
369
367
|
startedAt?: number;
|
|
370
368
|
// subagent
|
|
371
369
|
task?: string;
|
|
372
370
|
promptOffset?: number;
|
|
373
371
|
model?: string;
|
|
372
|
+
tools?: string[];
|
|
373
|
+
messageTempDirs?: Array<{ dir: string; afterAgentEnd: number }>;
|
|
374
374
|
depth?: number;
|
|
375
375
|
maxDepth?: number;
|
|
376
376
|
}
|
|
@@ -395,16 +395,52 @@ function readMeta(id: string): Meta | null {
|
|
|
395
395
|
}
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
+
function cleanupMessageTempDirs(id: string, meta: Meta, agentEnds: number): void {
|
|
399
|
+
if (!meta.messageTempDirs?.length) return;
|
|
400
|
+
const keep: NonNullable<Meta["messageTempDirs"]> = [];
|
|
401
|
+
for (const entry of meta.messageTempDirs) {
|
|
402
|
+
if (entry.afterAgentEnd > agentEnds) {
|
|
403
|
+
keep.push(entry);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
fs.rmSync(entry.dir, { recursive: true, force: true });
|
|
408
|
+
} catch {
|
|
409
|
+
/* best-effort */
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (keep.length > 0) meta.messageTempDirs = keep;
|
|
413
|
+
else delete meta.messageTempDirs;
|
|
414
|
+
writeMeta(id, meta);
|
|
415
|
+
}
|
|
416
|
+
|
|
398
417
|
export function shouldDeliverProcessCompletion(
|
|
399
|
-
meta:
|
|
400
|
-
|
|
401
|
-
notified?: boolean;
|
|
402
|
-
notificationPaused?: boolean;
|
|
403
|
-
} | null,
|
|
404
|
-
): meta is { kind: "process"; notified?: boolean; notificationPaused?: boolean } {
|
|
418
|
+
meta: Meta | null,
|
|
419
|
+
): meta is Meta & { kind: "process" } {
|
|
405
420
|
return meta?.kind === "process" && !meta.notified && !meta.notificationPaused;
|
|
406
421
|
}
|
|
407
422
|
|
|
423
|
+
export function shouldDeferCompletionNotification(agentIsIdle: boolean): boolean {
|
|
424
|
+
return !agentIsIdle;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function shouldKeepPolling(
|
|
428
|
+
sessions: Array<{ id: string; state: string }>,
|
|
429
|
+
metaFor: (id: string) => Meta | null,
|
|
430
|
+
): boolean {
|
|
431
|
+
return sessions.some(
|
|
432
|
+
(session) =>
|
|
433
|
+
session.state === "running" || shouldDeliverProcessCompletion(metaFor(session.id)),
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function shouldKeepPollingAfterList(
|
|
438
|
+
listed: { sessions: Array<{ id: string; state: string }>; error?: string },
|
|
439
|
+
metaFor: (id: string) => Meta | null,
|
|
440
|
+
): boolean {
|
|
441
|
+
return Boolean(listed.error) || shouldKeepPolling(listed.sessions, metaFor);
|
|
442
|
+
}
|
|
443
|
+
|
|
408
444
|
const kindOf = (id: string): "process" | "subagent" => readMeta(id)?.kind ?? "process";
|
|
409
445
|
|
|
410
446
|
// Compact elapsed formatting: "42s", "3m12s", "1h04m".
|
|
@@ -442,6 +478,44 @@ async function sendRpc(
|
|
|
442
478
|
}
|
|
443
479
|
}
|
|
444
480
|
|
|
481
|
+
export function parseRpcResponseBytes(
|
|
482
|
+
bytes: Buffer,
|
|
483
|
+
since: number,
|
|
484
|
+
command: string,
|
|
485
|
+
):
|
|
486
|
+
| { ok: true; data?: Record<string, unknown>; offset: number }
|
|
487
|
+
| { ok: false; error: string } {
|
|
488
|
+
let start = 0;
|
|
489
|
+
while (start < bytes.length) {
|
|
490
|
+
const newline = bytes.indexOf(0x0a, start);
|
|
491
|
+
const end = newline < 0 ? bytes.length : newline + 1;
|
|
492
|
+
const line = bytes
|
|
493
|
+
.subarray(start, newline < 0 ? end : newline)
|
|
494
|
+
.toString("utf8")
|
|
495
|
+
.replace(/\r$/, "")
|
|
496
|
+
.trim();
|
|
497
|
+
if (line.startsWith("{")) {
|
|
498
|
+
try {
|
|
499
|
+
const event = JSON.parse(line);
|
|
500
|
+
if (event.type === "response" && event.command === command) {
|
|
501
|
+
if (event.success === false) {
|
|
502
|
+
return { ok: false, error: clip(String(event.error ?? `${command} failed`)) };
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
ok: true,
|
|
506
|
+
data: event.data as Record<string, unknown> | undefined,
|
|
507
|
+
offset: since + end,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
} catch {
|
|
511
|
+
/* partial line */
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
start = end;
|
|
515
|
+
}
|
|
516
|
+
return { ok: false, error: `no ${command} response found in log` };
|
|
517
|
+
}
|
|
518
|
+
|
|
445
519
|
// Wait for `{"type":"response","command":<command>}` after `since`, then parse
|
|
446
520
|
// it. Distinguishes: success, explicit failure (success:false → the error
|
|
447
521
|
// message, e.g. "No API key found for …"), subagent death, and timeout — this
|
|
@@ -452,7 +526,10 @@ async function rpcResponse(
|
|
|
452
526
|
command: string,
|
|
453
527
|
timeout = "30s",
|
|
454
528
|
signal?: AbortSignal,
|
|
455
|
-
): Promise<
|
|
529
|
+
): Promise<
|
|
530
|
+
| { ok: true; data?: Record<string, unknown>; offset: number }
|
|
531
|
+
| { ok: false; error: string }
|
|
532
|
+
> {
|
|
456
533
|
const e = await bs(
|
|
457
534
|
["expect", "-s", id, "--since", String(since), "--timeout", timeout, `"command":"${command}"`],
|
|
458
535
|
{ signal },
|
|
@@ -476,23 +553,26 @@ async function rpcResponse(
|
|
|
476
553
|
: e.stderr || `expect failed (code ${e.code})`,
|
|
477
554
|
};
|
|
478
555
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const
|
|
482
|
-
|
|
556
|
+
try {
|
|
557
|
+
const file = logPath(id);
|
|
558
|
+
const size = fs.statSync(file).size;
|
|
559
|
+
const length = Math.max(0, size - since);
|
|
560
|
+
const bytes = Buffer.allocUnsafe(length);
|
|
561
|
+
const fd = fs.openSync(file, "r");
|
|
562
|
+
let read = 0;
|
|
483
563
|
try {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
if (
|
|
487
|
-
|
|
488
|
-
}
|
|
489
|
-
return { ok: true, data: ev.data as Record<string, unknown> | undefined };
|
|
564
|
+
while (read < length) {
|
|
565
|
+
const count = fs.readSync(fd, bytes, read, length - read, since + read);
|
|
566
|
+
if (count === 0) break;
|
|
567
|
+
read += count;
|
|
490
568
|
}
|
|
491
|
-
}
|
|
492
|
-
|
|
569
|
+
} finally {
|
|
570
|
+
fs.closeSync(fd);
|
|
493
571
|
}
|
|
572
|
+
return parseRpcResponseBytes(bytes.subarray(0, read), since, command);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
return { ok: false, error: `could not read ${command} response: ${String(error)}` };
|
|
494
575
|
}
|
|
495
|
-
return { ok: false, error: `no ${command} response found in log` };
|
|
496
576
|
}
|
|
497
577
|
|
|
498
578
|
// "5m" / "30s" / "2h" → milliseconds (null = no limit).
|
|
@@ -528,14 +608,26 @@ const NOTIFY_COMMAND_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_COMMAND_MAX
|
|
|
528
608
|
const NOTIFY_BATCH_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_BATCH_MAX_BYTES", 8_000);
|
|
529
609
|
const ANSWER_MAX_BYTES = 24_000; // subagent answers / error messages
|
|
530
610
|
|
|
531
|
-
function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
|
|
611
|
+
export function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
|
|
612
|
+
if (maxBytes <= 0) return "";
|
|
532
613
|
const buf = Buffer.from(s, "utf8");
|
|
533
614
|
if (buf.length <= maxBytes) return s;
|
|
534
|
-
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
615
|
+
|
|
616
|
+
// The marker counts toward the limit. Recompute a few times because the
|
|
617
|
+
// omitted-byte count can change the marker's digit width.
|
|
618
|
+
let available = maxBytes;
|
|
619
|
+
let marker = "";
|
|
620
|
+
for (let i = 0; i < 3; i++) {
|
|
621
|
+
marker = `\n… [${buf.length - available} bytes elided] …\n`;
|
|
622
|
+
available = Math.max(0, maxBytes - Buffer.byteLength(marker, "utf8"));
|
|
623
|
+
}
|
|
624
|
+
if (Buffer.byteLength(marker, "utf8") > maxBytes) return truncateUtf8End(marker, maxBytes);
|
|
625
|
+
const headBytes = Math.floor(available / 2);
|
|
626
|
+
const tailBytes = available - headBytes;
|
|
627
|
+
// Strip replacement chars from a mid-codepoint cut at either boundary.
|
|
628
|
+
const head = buf.subarray(0, headBytes).toString("utf8").replace(/\uFFFD+$/, "");
|
|
629
|
+
const tail = buf.subarray(buf.length - tailBytes).toString("utf8").replace(/^\uFFFD+/, "");
|
|
630
|
+
return `${head}${marker}${tail}`;
|
|
539
631
|
}
|
|
540
632
|
|
|
541
633
|
async function searchLog(
|
|
@@ -855,19 +947,27 @@ export function deliverProcessCompletionMessage(
|
|
|
855
947
|
// (A subagent-profile run does NOT carry the marker: ending a turn to "wait"
|
|
856
948
|
// for a subagent is a guidance violation, and treating it as completion keeps
|
|
857
949
|
// the parent from hanging forever.) `process` is the legacy pi-processes tool.
|
|
858
|
-
function
|
|
859
|
-
|
|
950
|
+
function isParkedMessages(
|
|
951
|
+
messages: { role?: string; toolName?: string; content?: unknown }[] | undefined,
|
|
860
952
|
): boolean {
|
|
861
|
-
if (!
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
return true;
|
|
953
|
+
if (!messages) return false;
|
|
954
|
+
// Models sometimes add a short assistant note after starting a process. Scan
|
|
955
|
+
// the run rather than requiring the marker-bearing tool result to be the last
|
|
956
|
+
// message, otherwise that harmless note turns a parked build into false task
|
|
957
|
+
// completion (and lets the self-reaper kill it).
|
|
958
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
959
|
+
const message = messages[i];
|
|
960
|
+
if (message?.role !== "toolResult") continue;
|
|
961
|
+
if (message.toolName === "process") return true; // legacy pi-processes
|
|
962
|
+
if (message.toolName !== "babysit_run") continue;
|
|
963
|
+
try {
|
|
964
|
+
const serialized = JSON.stringify(message.content ?? null);
|
|
965
|
+
if (serialized === "null" || serialized.includes(NOTIFY_MARKER)) return true;
|
|
966
|
+
} catch {
|
|
967
|
+
return true; // content unavailable — never false-kill a possible build wait
|
|
968
|
+
}
|
|
870
969
|
}
|
|
970
|
+
return false;
|
|
871
971
|
}
|
|
872
972
|
|
|
873
973
|
// ---------------------------------------------------------------------------
|
|
@@ -888,6 +988,7 @@ export interface Progress {
|
|
|
888
988
|
// RPC lifecycle bookkeeping (computed over the analyzed log slice):
|
|
889
989
|
agentStarts: number;
|
|
890
990
|
agentEnds: number;
|
|
991
|
+
agentSettled: number;
|
|
891
992
|
lastEndWasProcessWait: boolean;
|
|
892
993
|
running: boolean; // an agent run is in flight right now
|
|
893
994
|
waitingOnProcess: boolean; // idle, but a process resume is pending
|
|
@@ -921,82 +1022,158 @@ function summarizeToolCall(name: string, args: Record<string, unknown>): string
|
|
|
921
1022
|
}
|
|
922
1023
|
}
|
|
923
1024
|
|
|
924
|
-
|
|
925
|
-
|
|
1025
|
+
function emptyProgress(): Progress {
|
|
1026
|
+
return {
|
|
926
1027
|
turns: 0,
|
|
927
1028
|
toolCalls: [],
|
|
928
1029
|
finalText: "",
|
|
929
1030
|
agentStarts: 0,
|
|
930
1031
|
agentEnds: 0,
|
|
1032
|
+
agentSettled: 0,
|
|
931
1033
|
lastEndWasProcessWait: false,
|
|
932
1034
|
running: false,
|
|
933
1035
|
waitingOnProcess: false,
|
|
934
1036
|
done: false,
|
|
935
1037
|
};
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function updateProgressState(progress: Progress): Progress {
|
|
1041
|
+
progress.running = progress.agentStarts > progress.agentEnds;
|
|
1042
|
+
progress.waitingOnProcess =
|
|
1043
|
+
!progress.running && progress.agentEnds > 0 && progress.lastEndWasProcessWait;
|
|
1044
|
+
// `agent_end` is not final: Pi may still retry, compact-and-retry, or process
|
|
1045
|
+
// queued continuations. `agent_settled` is the authoritative completion event.
|
|
1046
|
+
progress.done =
|
|
1047
|
+
!progress.running &&
|
|
1048
|
+
progress.agentSettled > 0 &&
|
|
1049
|
+
!progress.lastEndWasProcessWait;
|
|
1050
|
+
return progress;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function parseEventLine(progress: Progress, raw: string): void {
|
|
1054
|
+
const line = raw.replace(/\r$/, "").trim();
|
|
1055
|
+
if (!line.startsWith("{")) return;
|
|
1056
|
+
let event: Record<string, unknown>;
|
|
1057
|
+
try {
|
|
1058
|
+
event = JSON.parse(line);
|
|
1059
|
+
} catch {
|
|
1060
|
+
return; // partial trailing line, etc.
|
|
1061
|
+
}
|
|
1062
|
+
switch (event.type) {
|
|
1063
|
+
case "turn_start":
|
|
1064
|
+
progress.turns++;
|
|
1065
|
+
break;
|
|
1066
|
+
case "tool_execution_start": {
|
|
1067
|
+
const name = String(event.toolName ?? "tool");
|
|
1068
|
+
progress.toolCalls.push({
|
|
1069
|
+
name,
|
|
1070
|
+
summary: summarizeToolCall(name, (event.args as Record<string, unknown>) ?? {}),
|
|
1071
|
+
});
|
|
1072
|
+
break;
|
|
944
1073
|
}
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
const
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
const msg = ev.message as
|
|
959
|
-
| { role?: string; content?: { type: string; text?: string }[]; usage?: { totalTokens?: number; cost?: { total?: number } } }
|
|
960
|
-
| undefined;
|
|
961
|
-
if (msg?.role === "assistant") {
|
|
962
|
-
const txt = (msg.content ?? [])
|
|
963
|
-
.filter((c) => c.type === "text" && c.text)
|
|
964
|
-
.map((c) => c.text)
|
|
965
|
-
.join("");
|
|
966
|
-
if (txt.trim()) p.finalText = txt; // keep the latest non-empty assistant text
|
|
967
|
-
if (msg.usage) {
|
|
968
|
-
p.tokens = msg.usage.totalTokens;
|
|
969
|
-
p.cost = msg.usage.cost?.total;
|
|
970
|
-
}
|
|
1074
|
+
case "message_end": {
|
|
1075
|
+
const message = event.message as
|
|
1076
|
+
| { role?: string; content?: { type: string; text?: string }[]; usage?: { totalTokens?: number; cost?: { total?: number } } }
|
|
1077
|
+
| undefined;
|
|
1078
|
+
if (message?.role === "assistant") {
|
|
1079
|
+
const text = (message.content ?? [])
|
|
1080
|
+
.filter((content) => content.type === "text" && content.text)
|
|
1081
|
+
.map((content) => content.text)
|
|
1082
|
+
.join("");
|
|
1083
|
+
if (text.trim()) progress.finalText = text;
|
|
1084
|
+
if (message.usage) {
|
|
1085
|
+
progress.tokens = message.usage.totalTokens;
|
|
1086
|
+
progress.cost = message.usage.cost?.total;
|
|
971
1087
|
}
|
|
972
|
-
break;
|
|
973
1088
|
}
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1089
|
+
break;
|
|
1090
|
+
}
|
|
1091
|
+
case "agent_start":
|
|
1092
|
+
progress.agentStarts++;
|
|
1093
|
+
break;
|
|
1094
|
+
case "agent_end": {
|
|
1095
|
+
progress.agentEnds++;
|
|
1096
|
+
progress.lastEndWasProcessWait = isParkedMessages(
|
|
1097
|
+
event.messages as
|
|
980
1098
|
| { role?: string; toolName?: string; content?: unknown }[]
|
|
981
|
-
| undefined
|
|
982
|
-
|
|
983
|
-
|
|
1099
|
+
| undefined,
|
|
1100
|
+
);
|
|
1101
|
+
break;
|
|
1102
|
+
}
|
|
1103
|
+
case "agent_settled":
|
|
1104
|
+
progress.agentSettled++;
|
|
1105
|
+
break;
|
|
1106
|
+
case "response":
|
|
1107
|
+
if (event.success === false) {
|
|
1108
|
+
progress.errorMsg = String(event.error ?? `rpc ${event.command ?? "command"} failed`);
|
|
984
1109
|
}
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1110
|
+
break;
|
|
1111
|
+
case "error":
|
|
1112
|
+
progress.errorMsg = String(event.message ?? event.error ?? line);
|
|
1113
|
+
break;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
export function parseEvents(logText: string): Progress {
|
|
1118
|
+
const progress = emptyProgress();
|
|
1119
|
+
for (const line of logText.split("\n")) parseEventLine(progress, line);
|
|
1120
|
+
return updateProgressState(progress);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
interface TaskProgressCache {
|
|
1124
|
+
base: number;
|
|
1125
|
+
offset: number;
|
|
1126
|
+
pending: Buffer;
|
|
1127
|
+
progress: Progress;
|
|
1128
|
+
}
|
|
1129
|
+
const taskProgressCache = new Map<string, TaskProgressCache>();
|
|
1130
|
+
|
|
1131
|
+
/** Parse only bytes appended since the previous observation of this task. */
|
|
1132
|
+
function taskProgressOf(id: string): { progress: Progress; offset: number } {
|
|
1133
|
+
const base = readMeta(id)?.promptOffset ?? 0;
|
|
1134
|
+
const file = logPath(id);
|
|
1135
|
+
const size = fs.statSync(file).size;
|
|
1136
|
+
let cached = taskProgressCache.get(id);
|
|
1137
|
+
if (!cached || cached.base !== base || size < cached.offset) {
|
|
1138
|
+
cached = { base, offset: base, pending: Buffer.alloc(0), progress: emptyProgress() };
|
|
1139
|
+
taskProgressCache.set(id, cached);
|
|
1140
|
+
}
|
|
1141
|
+
if (size > cached.offset) {
|
|
1142
|
+
const added = Buffer.allocUnsafe(size - cached.offset);
|
|
1143
|
+
const fd = fs.openSync(file, "r");
|
|
1144
|
+
let read = 0;
|
|
1145
|
+
try {
|
|
1146
|
+
while (read < added.length) {
|
|
1147
|
+
const count = fs.readSync(fd, added, read, added.length - read, cached.offset + read);
|
|
1148
|
+
if (count === 0) break;
|
|
1149
|
+
read += count;
|
|
1150
|
+
}
|
|
1151
|
+
} finally {
|
|
1152
|
+
fs.closeSync(fd);
|
|
994
1153
|
}
|
|
1154
|
+
cached.offset += read;
|
|
1155
|
+
const bytes = cached.pending.length
|
|
1156
|
+
? Buffer.concat([cached.pending, added.subarray(0, read)])
|
|
1157
|
+
: added.subarray(0, read);
|
|
1158
|
+
let start = 0;
|
|
1159
|
+
for (let index = 0; index < bytes.length; index++) {
|
|
1160
|
+
if (bytes[index] !== 0x0a) continue;
|
|
1161
|
+
parseEventLine(cached.progress, bytes.subarray(start, index).toString("utf8"));
|
|
1162
|
+
start = index + 1;
|
|
1163
|
+
}
|
|
1164
|
+
cached.pending = Buffer.from(bytes.subarray(start));
|
|
1165
|
+
}
|
|
1166
|
+
updateProgressState(cached.progress);
|
|
1167
|
+
if (cached.progress.agentEnds > 0) {
|
|
1168
|
+
const meta = readMeta(id);
|
|
1169
|
+
if (meta) cleanupMessageTempDirs(id, meta, cached.progress.agentEnds);
|
|
995
1170
|
}
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1171
|
+
// If the writer was observed mid-record, let `babysit expect --since` start
|
|
1172
|
+
// before that partial line so a split agent_settled marker cannot be missed.
|
|
1173
|
+
return {
|
|
1174
|
+
progress: cached.progress,
|
|
1175
|
+
offset: cached.offset - cached.pending.length,
|
|
1176
|
+
};
|
|
1000
1177
|
}
|
|
1001
1178
|
|
|
1002
1179
|
// ---------------------------------------------------------------------------
|
|
@@ -1073,13 +1250,32 @@ function writePromptTempFile(agentName: string, prompt: string): string {
|
|
|
1073
1250
|
// Anything over this budget is written to a file instead, and only a short
|
|
1074
1251
|
// "read this file" instruction travels through the PTY.
|
|
1075
1252
|
const PTY_SAFE_MESSAGE_BYTES = 600;
|
|
1253
|
+
const messageNeedsReadTool = (text: string): boolean =>
|
|
1254
|
+
Buffer.byteLength(text, "utf-8") > PTY_SAFE_MESSAGE_BYTES;
|
|
1255
|
+
|
|
1256
|
+
interface DeliverableMessage {
|
|
1257
|
+
message: string;
|
|
1258
|
+
tempDir?: string;
|
|
1259
|
+
}
|
|
1076
1260
|
|
|
1077
|
-
function deliverableMessage(kind: "task" | "steering message", text: string):
|
|
1078
|
-
if (
|
|
1261
|
+
function deliverableMessage(kind: "task" | "steering message", text: string): DeliverableMessage {
|
|
1262
|
+
if (!messageNeedsReadTool(text)) return { message: text };
|
|
1079
1263
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-babysit-msg-"));
|
|
1080
1264
|
const file = path.join(dir, "message.md");
|
|
1081
1265
|
fs.writeFileSync(file, text, "utf-8");
|
|
1082
|
-
return
|
|
1266
|
+
return {
|
|
1267
|
+
message: `Your full ${kind} is in the file ${file} — read it with the Read tool FIRST, then carry it out exactly as written.`,
|
|
1268
|
+
tempDir: dir,
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function discardDelivery(delivery: DeliverableMessage): void {
|
|
1273
|
+
if (!delivery.tempDir) return;
|
|
1274
|
+
try {
|
|
1275
|
+
fs.rmSync(delivery.tempDir, { recursive: true, force: true });
|
|
1276
|
+
} catch {
|
|
1277
|
+
/* best-effort */
|
|
1278
|
+
}
|
|
1083
1279
|
}
|
|
1084
1280
|
|
|
1085
1281
|
interface SubagentOpts {
|
|
@@ -1107,12 +1303,29 @@ async function spawnSubagent(
|
|
|
1107
1303
|
const model = opts.model ?? opts.agent?.model;
|
|
1108
1304
|
if (model) piArgs.push("--model", model);
|
|
1109
1305
|
const tools = opts.tools ?? opts.agent?.tools;
|
|
1306
|
+
if (messageNeedsReadTool(opts.task) && tools?.length && !tools.includes("read")) {
|
|
1307
|
+
return {
|
|
1308
|
+
error:
|
|
1309
|
+
"This task exceeds the PTY-safe inline limit and must be delivered through a file, " +
|
|
1310
|
+
"but the subagent tool allowlist excludes `read`. Add `read` or shorten the task.",
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1110
1313
|
if (tools && tools.length > 0) piArgs.push("--tools", tools.join(","));
|
|
1111
1314
|
piArgs.push("--append-system-prompt", SUBAGENT_GUIDANCE);
|
|
1315
|
+
let promptTempFile: string | undefined;
|
|
1112
1316
|
if (opts.agent?.systemPrompt?.trim()) {
|
|
1113
|
-
|
|
1114
|
-
piArgs.push("--append-system-prompt",
|
|
1317
|
+
promptTempFile = writePromptTempFile(opts.agent.name, opts.agent.systemPrompt);
|
|
1318
|
+
piArgs.push("--append-system-prompt", promptTempFile);
|
|
1115
1319
|
}
|
|
1320
|
+
const cleanupPromptTemp = () => {
|
|
1321
|
+
if (!promptTempFile) return;
|
|
1322
|
+
try {
|
|
1323
|
+
fs.rmSync(path.dirname(promptTempFile), { recursive: true, force: true });
|
|
1324
|
+
} catch {
|
|
1325
|
+
/* best-effort */
|
|
1326
|
+
}
|
|
1327
|
+
promptTempFile = undefined;
|
|
1328
|
+
};
|
|
1116
1329
|
// Self-reaper: a finished subagent exits after a short idle grace instead of
|
|
1117
1330
|
// lingering until the absolute --timeout. Cancelled by a follow-up task, and
|
|
1118
1331
|
// it never reaps a turn parked on a process-exit notification. See self-reap.ts.
|
|
@@ -1153,12 +1366,14 @@ async function spawnSubagent(
|
|
|
1153
1366
|
},
|
|
1154
1367
|
});
|
|
1155
1368
|
if (r.code !== 0) {
|
|
1369
|
+
cleanupPromptTemp();
|
|
1156
1370
|
return { error: r.stderr || r.stdout || `babysit run failed (exit ${r.code}, no output) — check that \`${BABYSIT_BIN}\` works and ${ROOT} is writable` };
|
|
1157
1371
|
}
|
|
1158
1372
|
let id: string;
|
|
1159
1373
|
try {
|
|
1160
1374
|
id = JSON.parse(r.stdout).id;
|
|
1161
1375
|
} catch {
|
|
1376
|
+
cleanupPromptTemp();
|
|
1162
1377
|
return { error: `could not parse id from: ${r.stdout}` };
|
|
1163
1378
|
}
|
|
1164
1379
|
|
|
@@ -1175,12 +1390,25 @@ async function spawnSubagent(
|
|
|
1175
1390
|
});
|
|
1176
1391
|
|
|
1177
1392
|
// Wait for pi to boot (first JSON event in the log), then inject the task.
|
|
1178
|
-
|
|
1393
|
+
// Pi has loaded --append-system-prompt before emitting that event, so the
|
|
1394
|
+
// anonymous prompt file can be removed immediately instead of leaking.
|
|
1395
|
+
const boot = await bs(["expect", "-s", id, "--timeout", "30s", '\\{"type"']);
|
|
1396
|
+
cleanupPromptTemp();
|
|
1397
|
+
if (boot.code !== 0) {
|
|
1398
|
+
await bs(["kill", "-s", id]);
|
|
1399
|
+
return {
|
|
1400
|
+
error: boot.code === 124
|
|
1401
|
+
? `subagent ${id} did not emit an RPC startup event within 30s`
|
|
1402
|
+
: boot.stderr || `subagent ${id} startup probe failed (code ${boot.code})`,
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
const delivery = deliverableMessage("task", opts.task);
|
|
1179
1406
|
const sent = await sendRpc(id, {
|
|
1180
1407
|
type: "prompt",
|
|
1181
|
-
message: `Task: ${
|
|
1408
|
+
message: `Task: ${delivery.message}`,
|
|
1182
1409
|
});
|
|
1183
1410
|
if ("error" in sent) {
|
|
1411
|
+
discardDelivery(delivery);
|
|
1184
1412
|
await bs(["kill", "-s", id]);
|
|
1185
1413
|
return { error: `could not send task to subagent ${id}: ${sent.error}` };
|
|
1186
1414
|
}
|
|
@@ -1188,6 +1416,7 @@ async function spawnSubagent(
|
|
|
1188
1416
|
// and similar config errors surface — fail the spawn loudly).
|
|
1189
1417
|
const resp = await rpcResponse(id, sent.offset, "prompt", "60s");
|
|
1190
1418
|
if (!resp.ok) {
|
|
1419
|
+
discardDelivery(delivery);
|
|
1191
1420
|
await bs(["kill", "-s", id]);
|
|
1192
1421
|
return { error: `subagent ${id} rejected the task: ${resp.error}` };
|
|
1193
1422
|
}
|
|
@@ -1200,6 +1429,7 @@ async function spawnSubagent(
|
|
|
1200
1429
|
if (st.ok && st.data) {
|
|
1201
1430
|
const m = (st.data as { model?: { id?: string } | null }).model;
|
|
1202
1431
|
if (m === null) {
|
|
1432
|
+
discardDelivery(delivery);
|
|
1203
1433
|
await bs(["kill", "-s", id]);
|
|
1204
1434
|
return {
|
|
1205
1435
|
error: `subagent ${id} has no usable model${opts.model ? ` (requested "${opts.model}")` : ""} — check the model name with \`pi --list-models\`.`,
|
|
@@ -1212,8 +1442,12 @@ async function spawnSubagent(
|
|
|
1212
1442
|
writeMeta(id, {
|
|
1213
1443
|
kind: "subagent",
|
|
1214
1444
|
task: opts.task,
|
|
1215
|
-
promptOffset:
|
|
1445
|
+
promptOffset: resp.offset,
|
|
1216
1446
|
model: resolvedModel,
|
|
1447
|
+
tools,
|
|
1448
|
+
messageTempDirs: delivery.tempDir
|
|
1449
|
+
? [{ dir: delivery.tempDir, afterAgentEnd: 1 }]
|
|
1450
|
+
: undefined,
|
|
1217
1451
|
depth: opts.depth,
|
|
1218
1452
|
maxDepth: opts.maxDepth,
|
|
1219
1453
|
startedAt: Date.now(),
|
|
@@ -1257,43 +1491,31 @@ function sanitizeTailLine(s: string): string {
|
|
|
1257
1491
|
// Trailing lines to show for a running session (sanitized, unprefixed).
|
|
1258
1492
|
// process → raw log tail; subagent → derived activity (recent tool calls /
|
|
1259
1493
|
// partial answer), since its log is RPC JSONL, not human-readable.
|
|
1260
|
-
async function widgetTail(
|
|
1494
|
+
async function widgetTail(
|
|
1495
|
+
id: string,
|
|
1496
|
+
isSub: boolean,
|
|
1497
|
+
subagentProgress?: Progress,
|
|
1498
|
+
): Promise<string[]> {
|
|
1261
1499
|
let raw: string[];
|
|
1262
1500
|
if (!isSub) {
|
|
1263
1501
|
const out = (await bs(["log", "-s", id, "--tail", String(WIDGET_TAIL_LINES)])).stdout;
|
|
1264
1502
|
raw = out.split("\n");
|
|
1265
1503
|
} else {
|
|
1266
|
-
const
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
raw = prog.finalText.trim().split("\n");
|
|
1272
|
-
} else if (prog.toolCalls.length > 0) {
|
|
1273
|
-
raw = prog.toolCalls.map((t) => t.summary);
|
|
1504
|
+
const progress = subagentProgress ?? taskProgressOf(id).progress;
|
|
1505
|
+
if (progress.finalText.trim()) {
|
|
1506
|
+
raw = progress.finalText.trim().split("\n");
|
|
1507
|
+
} else if (progress.toolCalls.length > 0) {
|
|
1508
|
+
raw = progress.toolCalls.map((tool) => tool.summary);
|
|
1274
1509
|
} else {
|
|
1275
|
-
raw =
|
|
1510
|
+
raw = progress.errorMsg ? [`⚠ ${progress.errorMsg}`] : [];
|
|
1276
1511
|
}
|
|
1277
1512
|
}
|
|
1278
1513
|
return raw
|
|
1279
1514
|
.map(sanitizeTailLine)
|
|
1280
|
-
.filter((
|
|
1515
|
+
.filter((line) => line.trim().length > 0)
|
|
1281
1516
|
.slice(-WIDGET_TAIL_LINES);
|
|
1282
1517
|
}
|
|
1283
1518
|
|
|
1284
|
-
// Classify a running subagent as busy or idle by analyzing the current task's
|
|
1285
|
-
// log slice (same logic babysit_check uses).
|
|
1286
|
-
async function isTaskDone(id: string): Promise<boolean> {
|
|
1287
|
-
try {
|
|
1288
|
-
const meta = readMeta(id);
|
|
1289
|
-
const logArgs = ["log", "-s", id];
|
|
1290
|
-
if (meta?.promptOffset) logArgs.push("--since", String(meta.promptOffset));
|
|
1291
|
-
return parseEvents((await bs(logArgs)).stdout).done;
|
|
1292
|
-
} catch {
|
|
1293
|
-
return false; // when unsure, treat as busy — never hide a live worker
|
|
1294
|
-
}
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
1519
|
// ---------------------------------------------------------------------------
|
|
1298
1520
|
// human take-over hint (no tmux dependency)
|
|
1299
1521
|
// ---------------------------------------------------------------------------
|
|
@@ -1318,32 +1540,25 @@ interface WaitOutcome {
|
|
|
1318
1540
|
progress?: Progress;
|
|
1319
1541
|
}
|
|
1320
1542
|
|
|
1321
|
-
// Wait for ONE subagent's current task. Completion =
|
|
1322
|
-
//
|
|
1323
|
-
//
|
|
1324
|
-
//
|
|
1325
|
-
// block on the next agent_end via `babysit expect` (race-free byte offsets).
|
|
1543
|
+
// Wait for ONE subagent's current task. Completion = agent_settled without a
|
|
1544
|
+
// parked babysit_run/process result (a parked run only means "waiting for
|
|
1545
|
+
// process exit; pi will resume itself"). Parse appended bytes incrementally,
|
|
1546
|
+
// then block on the next agent_settled via race-free byte offsets.
|
|
1326
1547
|
async function waitForTask(
|
|
1327
1548
|
id: string,
|
|
1328
1549
|
limitMs: number | null,
|
|
1329
1550
|
signal?: AbortSignal,
|
|
1330
1551
|
): Promise<WaitOutcome> {
|
|
1331
|
-
const meta = readMeta(id);
|
|
1332
|
-
const base = meta?.promptOffset ?? 0;
|
|
1333
1552
|
const t0 = Date.now();
|
|
1334
1553
|
|
|
1335
1554
|
for (;;) {
|
|
1336
|
-
|
|
1337
|
-
let text = "";
|
|
1338
|
-
let cur = base;
|
|
1555
|
+
let observed: { progress: Progress; offset: number };
|
|
1339
1556
|
try {
|
|
1340
|
-
|
|
1341
|
-
text = j.text ?? "";
|
|
1342
|
-
cur = j.offset ?? base;
|
|
1557
|
+
observed = taskProgressOf(id);
|
|
1343
1558
|
} catch {
|
|
1344
|
-
|
|
1559
|
+
observed = { progress: emptyProgress(), offset: readMeta(id)?.promptOffset ?? 0 };
|
|
1345
1560
|
}
|
|
1346
|
-
const prog =
|
|
1561
|
+
const { progress: prog, offset: cur } = observed;
|
|
1347
1562
|
const st = await statusOf(id);
|
|
1348
1563
|
|
|
1349
1564
|
const stats =
|
|
@@ -1400,7 +1615,8 @@ async function waitForTask(
|
|
|
1400
1615
|
progress: prog,
|
|
1401
1616
|
});
|
|
1402
1617
|
|
|
1403
|
-
// Still working — block
|
|
1618
|
+
// Still working — block until Pi declares the run fully settled. Unlike
|
|
1619
|
+
// agent_end, this cannot fire before an automatic retry/compaction retry.
|
|
1404
1620
|
let expectTimeout = "0"; // indefinite
|
|
1405
1621
|
if (limitMs != null) {
|
|
1406
1622
|
const remaining = limitMs - (Date.now() - t0);
|
|
@@ -1408,7 +1624,7 @@ async function waitForTask(
|
|
|
1408
1624
|
expectTimeout = `${Math.ceil(remaining / 1000)}s`;
|
|
1409
1625
|
}
|
|
1410
1626
|
const e = await bs(
|
|
1411
|
-
["expect", "-s", id, "--since", String(cur), "--timeout", expectTimeout, '"type":"
|
|
1627
|
+
["expect", "-s", id, "--since", String(cur), "--timeout", expectTimeout, '"type":"agent_settled"'],
|
|
1412
1628
|
{ signal },
|
|
1413
1629
|
);
|
|
1414
1630
|
if (signal?.aborted || e.code === 130) {
|
|
@@ -1442,19 +1658,47 @@ function suppressNotify(id: string, reason: "observed" | "kill" = "observed"): v
|
|
|
1442
1658
|
}
|
|
1443
1659
|
}
|
|
1444
1660
|
|
|
1445
|
-
export
|
|
1661
|
+
export interface WaitReservationState {
|
|
1446
1662
|
notified?: boolean;
|
|
1447
1663
|
killNotificationSuppressed?: boolean;
|
|
1448
|
-
|
|
1449
|
-
|
|
1664
|
+
waitReservations?: number;
|
|
1665
|
+
waitCompletionClaimed?: boolean;
|
|
1450
1666
|
}
|
|
1451
1667
|
|
|
1452
|
-
function
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
meta.
|
|
1456
|
-
|
|
1668
|
+
export function canRestoreNotificationAfterWait(meta: WaitReservationState): boolean {
|
|
1669
|
+
return (
|
|
1670
|
+
meta.notified === true &&
|
|
1671
|
+
meta.killNotificationSuppressed !== true &&
|
|
1672
|
+
meta.waitCompletionClaimed !== true &&
|
|
1673
|
+
(meta.waitReservations ?? 0) === 0
|
|
1674
|
+
);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
export function transitionWaitReservation(
|
|
1678
|
+
state: WaitReservationState,
|
|
1679
|
+
action: "reserve" | "abandon" | "claim",
|
|
1680
|
+
): WaitReservationState {
|
|
1681
|
+
const next = { ...state };
|
|
1682
|
+
if (action === "reserve") {
|
|
1683
|
+
next.waitReservations = (next.waitReservations ?? 0) + 1;
|
|
1684
|
+
next.notified = true;
|
|
1685
|
+
return next;
|
|
1457
1686
|
}
|
|
1687
|
+
|
|
1688
|
+
next.waitReservations = Math.max(0, (next.waitReservations ?? 0) - 1);
|
|
1689
|
+
if (action === "claim") next.waitCompletionClaimed = true;
|
|
1690
|
+
if (action === "claim" || next.waitCompletionClaimed) {
|
|
1691
|
+
next.notified = true;
|
|
1692
|
+
} else if (canRestoreNotificationAfterWait(next)) {
|
|
1693
|
+
next.notified = false;
|
|
1694
|
+
}
|
|
1695
|
+
return next;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
function updateWaitReservation(id: string, action: "reserve" | "abandon" | "claim"): void {
|
|
1699
|
+
const meta = readMeta(id);
|
|
1700
|
+
if (!meta || meta.kind !== "process") return;
|
|
1701
|
+
writeMeta(id, { ...meta, ...transitionWaitReservation(meta, action) });
|
|
1458
1702
|
}
|
|
1459
1703
|
|
|
1460
1704
|
function pauseNotify(id: string): void {
|
|
@@ -1508,19 +1752,20 @@ async function waitForExit(
|
|
|
1508
1752
|
}
|
|
1509
1753
|
// fall through: session exited before the pattern appeared
|
|
1510
1754
|
} else {
|
|
1511
|
-
// An explicit wait owns completion delivery.
|
|
1512
|
-
//
|
|
1513
|
-
|
|
1755
|
+
// An explicit wait owns completion delivery. Reference-count the claim so
|
|
1756
|
+
// one concurrent wait timing out cannot re-enable notifications underneath
|
|
1757
|
+
// another wait that is still pending.
|
|
1758
|
+
updateWaitReservation(id, "reserve");
|
|
1514
1759
|
const w = await bs(["wait", "-s", id, "--timeout", t], { signal });
|
|
1515
1760
|
if (signal?.aborted || w.code === 130) {
|
|
1516
|
-
|
|
1761
|
+
updateWaitReservation(id, "abandon");
|
|
1517
1762
|
return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
|
|
1518
1763
|
}
|
|
1519
1764
|
if (w.code === 124) {
|
|
1520
1765
|
// 124 is ambiguous (timeout vs child exiting 124) — disambiguate.
|
|
1521
1766
|
const st0 = await statusOf(id);
|
|
1522
1767
|
if (st0?.state === "running") {
|
|
1523
|
-
|
|
1768
|
+
updateWaitReservation(id, "abandon");
|
|
1524
1769
|
return {
|
|
1525
1770
|
id,
|
|
1526
1771
|
kind: "timeout",
|
|
@@ -1534,9 +1779,15 @@ async function waitForExit(
|
|
|
1534
1779
|
|
|
1535
1780
|
const st = await statusOf(id);
|
|
1536
1781
|
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
|
+
if (!expectPattern) updateWaitReservation(id, "claim");
|
|
1537
1787
|
return { id, kind: "exited", ok: false, text: `No such session: ${id}` };
|
|
1538
1788
|
}
|
|
1539
|
-
suppressNotify(id);
|
|
1789
|
+
if (expectPattern) suppressNotify(id);
|
|
1790
|
+
else updateWaitReservation(id, "claim"); // the agent sees the exit here; don't notify again
|
|
1540
1791
|
const meta = readMeta(id);
|
|
1541
1792
|
const workerDead = st.state === "dead" && st.exit_code == null;
|
|
1542
1793
|
const ok = st.exit_code === 0;
|
|
@@ -1588,12 +1839,42 @@ export function isAllowedDirectBash(_command: string): boolean {
|
|
|
1588
1839
|
return process.env.PI_BABYSIT_ALLOW_BASH === "1";
|
|
1589
1840
|
}
|
|
1590
1841
|
|
|
1842
|
+
export function activeToolsWithoutDirectBash(activeTools: string[], allowDirectBash: boolean): string[] {
|
|
1843
|
+
return allowDirectBash ? activeTools : activeTools.filter((name) => name !== "bash");
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1591
1846
|
// ---------------------------------------------------------------------------
|
|
1592
1847
|
// extension
|
|
1593
1848
|
// ---------------------------------------------------------------------------
|
|
1594
1849
|
|
|
1595
1850
|
export default function (pi: ExtensionAPI) {
|
|
1596
1851
|
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
1852
|
+
let pollNeeded = true;
|
|
1853
|
+
const declaredToolErrors = new Set<string>();
|
|
1854
|
+
|
|
1855
|
+
// Pi only persists custom-tool failures when they are thrown or patched by a
|
|
1856
|
+
// tool_result hook; an `isError` property returned from execute() is ignored.
|
|
1857
|
+
// Keep the structured result (status, log path, diagnostics) while promoting
|
|
1858
|
+
// its declared error bit at the supported hook boundary.
|
|
1859
|
+
const registerTool = <TParams extends TSchema>(
|
|
1860
|
+
tool: ToolDefinition<TParams, unknown, unknown>,
|
|
1861
|
+
): void => {
|
|
1862
|
+
const execute = tool.execute;
|
|
1863
|
+
pi.registerTool({
|
|
1864
|
+
...tool,
|
|
1865
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1866
|
+
const result = await execute(toolCallId, params, signal, onUpdate, ctx);
|
|
1867
|
+
if ((result as typeof result & { isError?: boolean }).isError === true) {
|
|
1868
|
+
declaredToolErrors.add(toolCallId);
|
|
1869
|
+
}
|
|
1870
|
+
return result;
|
|
1871
|
+
},
|
|
1872
|
+
});
|
|
1873
|
+
};
|
|
1874
|
+
|
|
1875
|
+
pi.on("tool_result", (event) => {
|
|
1876
|
+
if (declaredToolErrors.delete(event.toolCallId)) return { isError: true };
|
|
1877
|
+
});
|
|
1597
1878
|
|
|
1598
1879
|
// Exit notifications for kind=process sessions: the poller detects
|
|
1599
1880
|
// running→exited transitions and injects ONE message (triggerTurn) for all
|
|
@@ -1601,8 +1882,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1601
1882
|
// that ended its turn after babysit_run without spending one turn per exit.
|
|
1602
1883
|
// Kills via babysit_kill and exits already reported by babysit_wait are
|
|
1603
1884
|
// suppressed via meta.notified.
|
|
1604
|
-
async function notifyEndedProcesses(
|
|
1605
|
-
|
|
1885
|
+
async function notifyEndedProcesses(
|
|
1886
|
+
ctx: ExtensionContext,
|
|
1887
|
+
snapshot?: BsSession[],
|
|
1888
|
+
): Promise<void> {
|
|
1889
|
+
// Never steer a completion into an active agent turn. In particular, this
|
|
1890
|
+
// lets an immediately-following babysit_wait reserve the completion first,
|
|
1891
|
+
// instead of racing the poller and receiving both wait + auto notification.
|
|
1892
|
+
if (shouldDeferCompletionNotification(ctx.isIdle())) return;
|
|
1893
|
+
const sessions = snapshot ?? (await listSessions()).sessions;
|
|
1606
1894
|
const ready: Array<{ session: BsSession; meta: Meta }> = [];
|
|
1607
1895
|
for (const session of sessions) {
|
|
1608
1896
|
if (session.state === "running") continue;
|
|
@@ -1660,6 +1948,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1660
1948
|
metadataById.set(notice.id, current);
|
|
1661
1949
|
return [{ ...notice, command: current.command }];
|
|
1662
1950
|
});
|
|
1951
|
+
// Output collection above yields to the event loop. Re-check idleness so a
|
|
1952
|
+
// newly-started agent turn cannot receive a duplicate completion mid-turn.
|
|
1953
|
+
if (shouldDeferCompletionNotification(ctx.isIdle())) return;
|
|
1663
1954
|
deliverProcessCompletionMessage(
|
|
1664
1955
|
notices,
|
|
1665
1956
|
(message, options) => pi.sendMessage(message, options),
|
|
@@ -1673,30 +1964,45 @@ export default function (pi: ExtensionAPI) {
|
|
|
1673
1964
|
);
|
|
1674
1965
|
}
|
|
1675
1966
|
|
|
1676
|
-
const refreshWidget = async (ctx: ExtensionContext) => {
|
|
1967
|
+
const refreshWidget = async (ctx: ExtensionContext, snapshot?: BsSession[]) => {
|
|
1677
1968
|
if (!ctx.hasUI) return;
|
|
1678
|
-
const active = (await listSessions()).sessions.filter(
|
|
1679
|
-
|
|
1969
|
+
const active = (snapshot ?? (await listSessions()).sessions).filter(
|
|
1970
|
+
(session) => session.state === "running",
|
|
1971
|
+
);
|
|
1972
|
+
const subs = active.filter((session) => kindOf(session.id) === "subagent");
|
|
1680
1973
|
const procs = active.length - subs.length;
|
|
1681
|
-
const
|
|
1682
|
-
const
|
|
1974
|
+
const progressById = new Map<string, Progress>();
|
|
1975
|
+
for (const subagent of subs) {
|
|
1976
|
+
try {
|
|
1977
|
+
progressById.set(subagent.id, taskProgressOf(subagent.id).progress);
|
|
1978
|
+
} catch {
|
|
1979
|
+
progressById.set(subagent.id, emptyProgress());
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
const idle = subs.filter((session) => progressById.get(session.id)?.done).length;
|
|
1683
1983
|
const lines = renderWidgetLines(procs, subs.length - idle, idle);
|
|
1684
|
-
// Per-session elapsed time (live: refreshed every poll tick).
|
|
1685
|
-
const doneById = new Map(subs.map((s, i) => [s.id, doneFlags[i]]));
|
|
1686
1984
|
const tails = await Promise.all(
|
|
1687
|
-
active.map((
|
|
1985
|
+
active.map((session) => {
|
|
1986
|
+
const isSubagent = kindOf(session.id) === "subagent";
|
|
1987
|
+
return widgetTail(
|
|
1988
|
+
session.id,
|
|
1989
|
+
isSubagent,
|
|
1990
|
+
isSubagent ? progressById.get(session.id) : undefined,
|
|
1991
|
+
);
|
|
1992
|
+
}),
|
|
1688
1993
|
);
|
|
1689
|
-
active.forEach((
|
|
1690
|
-
const
|
|
1691
|
-
const tag =
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1994
|
+
active.forEach((session, index) => {
|
|
1995
|
+
const isSubagent = kindOf(session.id) === "subagent";
|
|
1996
|
+
const tag = isSubagent
|
|
1997
|
+
? progressById.get(session.id)?.done ? "sub idle" : "sub"
|
|
1998
|
+
: "proc";
|
|
1999
|
+
const elapsed = elapsedOf(session.id);
|
|
2000
|
+
const header = ` ⏳ ${session.id} [${tag}]${elapsed ? ` ${elapsed}` : ""}`;
|
|
2001
|
+
if (tails[index].length === 1) {
|
|
2002
|
+
lines.push(`${header} │ ${tails[index][0]}`);
|
|
1697
2003
|
} else {
|
|
1698
2004
|
lines.push(header);
|
|
1699
|
-
for (const
|
|
2005
|
+
for (const tail of tails[index]) lines.push(` │ ${tail}`);
|
|
1700
2006
|
}
|
|
1701
2007
|
});
|
|
1702
2008
|
ctx.ui.setWidget("pi-babysit", lines, { placement: "belowEditor" });
|
|
@@ -1773,6 +2079,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
1773
2079
|
|
|
1774
2080
|
let polling = false;
|
|
1775
2081
|
pi.on("session_start", async (_event, ctx) => {
|
|
2082
|
+
// Do not expose the built-in bash tool only to reject it after the model has
|
|
2083
|
+
// already paid for a failed tool turn. The tool_call hook remains a fallback
|
|
2084
|
+
// if another extension/preset re-enables bash later in the session.
|
|
2085
|
+
const activeTools = pi.getActiveTools();
|
|
2086
|
+
const supervisedTools = activeToolsWithoutDirectBash(
|
|
2087
|
+
activeTools,
|
|
2088
|
+
isAllowedDirectBash(""),
|
|
2089
|
+
);
|
|
2090
|
+
if (supervisedTools.length !== activeTools.length) pi.setActiveTools(supervisedTools);
|
|
2091
|
+
|
|
1776
2092
|
// Session-local registry: scope the babysit root to this pi session so
|
|
1777
2093
|
// other sessions' processes/subagents are invisible here. Resuming a
|
|
1778
2094
|
// session keeps the same id, so its sessions come back with it.
|
|
@@ -1781,20 +2097,36 @@ export default function (pi: ExtensionAPI) {
|
|
|
1781
2097
|
} catch {
|
|
1782
2098
|
ROOT = ROOT_BASE;
|
|
1783
2099
|
}
|
|
2100
|
+
taskProgressCache.clear();
|
|
2101
|
+
pollNeeded = true;
|
|
1784
2102
|
// Warn early if the binary is missing so the user isn't surprised only when
|
|
1785
2103
|
// a tool later fails. Tools/commands still enforce it via requireBabysit.
|
|
1786
2104
|
if (ctx.hasUI && !(await babysitAvailable())) {
|
|
1787
|
-
ctx.ui.notify(babysitPreflightError ?? INSTALL_HINT, "
|
|
2105
|
+
ctx.ui.notify(babysitPreflightError ?? INSTALL_HINT, "warning");
|
|
1788
2106
|
}
|
|
1789
2107
|
if (pollTimer) clearInterval(pollTimer);
|
|
1790
2108
|
pollTimer = setInterval(() => {
|
|
1791
|
-
//
|
|
1792
|
-
//
|
|
1793
|
-
|
|
2109
|
+
// Once a namespace has no live workers or pending notifications, avoid
|
|
2110
|
+
// spawning `babysit list` forever while the UI is idle. A successful
|
|
2111
|
+
// babysit_run re-arms polling below.
|
|
2112
|
+
if (polling || !pollNeeded) return;
|
|
1794
2113
|
polling = true;
|
|
1795
|
-
|
|
2114
|
+
void (async () => {
|
|
2115
|
+
const listed = await listSessions();
|
|
2116
|
+
if (listed.error) {
|
|
2117
|
+
pollNeeded = shouldKeepPollingAfterList(listed, readMeta);
|
|
2118
|
+
throw new Error(listed.error);
|
|
2119
|
+
}
|
|
2120
|
+
const snapshot = listed.sessions;
|
|
2121
|
+
await Promise.all([
|
|
2122
|
+
notifyEndedProcesses(ctx, snapshot),
|
|
2123
|
+
refreshWidget(ctx, snapshot),
|
|
2124
|
+
]);
|
|
2125
|
+
pollNeeded = shouldKeepPolling(snapshot, readMeta);
|
|
2126
|
+
})()
|
|
1796
2127
|
.catch(() => {
|
|
1797
|
-
|
|
2128
|
+
// Keep polling armed after a transient CLI/filesystem error.
|
|
2129
|
+
pollNeeded = true;
|
|
1798
2130
|
})
|
|
1799
2131
|
.finally(() => {
|
|
1800
2132
|
polling = false;
|
|
@@ -1829,7 +2161,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1829
2161
|
});
|
|
1830
2162
|
|
|
1831
2163
|
// ----- babysit_run --------------------------------------------------------
|
|
1832
|
-
|
|
2164
|
+
registerTool({
|
|
1833
2165
|
name: "babysit_run",
|
|
1834
2166
|
label: "Babysit: run",
|
|
1835
2167
|
description:
|
|
@@ -1850,7 +2182,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1850
2182
|
"Run any shell command with context-safe captured output; quick commands return metadata, longer ones continue in background",
|
|
1851
2183
|
promptGuidelines: [
|
|
1852
2184
|
"Use babysit_run as the default for shell commands, not only long-running work. Small output is returned directly; large stdout/stderr stays out of model context in the returned log path. Give meaningful commands a clear stable `name`.",
|
|
1853
|
-
"
|
|
2185
|
+
"Bundle closely related tiny observations into one babysit_run command when that reduces tool turns without obscuring lifecycle or failure handling.",
|
|
2186
|
+
"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.",
|
|
1854
2187
|
"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').",
|
|
1855
2188
|
"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.",
|
|
1856
2189
|
"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 }.",
|
|
@@ -1984,6 +2317,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1984
2317
|
details: {},
|
|
1985
2318
|
};
|
|
1986
2319
|
}
|
|
2320
|
+
pollNeeded = true;
|
|
1987
2321
|
await refreshWidget(ctx);
|
|
1988
2322
|
|
|
1989
2323
|
// One-shot / non-interactive mode (e.g. `pi -p`): there is no live
|
|
@@ -2128,6 +2462,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2128
2462
|
};
|
|
2129
2463
|
}
|
|
2130
2464
|
|
|
2465
|
+
pollNeeded = true;
|
|
2131
2466
|
await refreshWidget(ctx);
|
|
2132
2467
|
return {
|
|
2133
2468
|
content: [
|
|
@@ -2199,7 +2534,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2199
2534
|
});
|
|
2200
2535
|
|
|
2201
2536
|
// ----- babysit_check ------------------------------------------------------
|
|
2202
|
-
|
|
2537
|
+
registerTool({
|
|
2203
2538
|
name: "babysit_check",
|
|
2204
2539
|
label: "Babysit: check",
|
|
2205
2540
|
description:
|
|
@@ -2257,7 +2592,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2257
2592
|
const preview = what.length > 60 ? `${what.slice(0, 57)}…` : what;
|
|
2258
2593
|
return `${s.id} [${kind}] ${s.state}${ec}${depth}${flag}${preview ? ` — ${preview}` : ""}`;
|
|
2259
2594
|
});
|
|
2260
|
-
return { content: [{ type: "text", text: lines.join("\n") }], details: { sessions } };
|
|
2595
|
+
return { content: [{ type: "text", text: clip(lines.join("\n")) }], details: { sessions } };
|
|
2261
2596
|
}
|
|
2262
2597
|
|
|
2263
2598
|
const st = await statusOf(params.id);
|
|
@@ -2299,7 +2634,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2299
2634
|
? `--- latest matches /${params.pattern}/ ---\n${result.text}`
|
|
2300
2635
|
: `(no output matching /${params.pattern}/)`;
|
|
2301
2636
|
return {
|
|
2302
|
-
content: [{ type: "text", text: `${header}\n${body}` }],
|
|
2637
|
+
content: [{ type: "text", text: clip(`${header}\n${body}`) }],
|
|
2303
2638
|
details: { status: st, kind, logPath: logPath(params.id), pattern: params.pattern },
|
|
2304
2639
|
};
|
|
2305
2640
|
}
|
|
@@ -2327,15 +2662,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
2327
2662
|
parts.push(tail ? `--- recent output ---\n${tail}` : "(no output yet)");
|
|
2328
2663
|
}
|
|
2329
2664
|
return {
|
|
2330
|
-
content: [{ type: "text", text: parts.join("\n") }],
|
|
2665
|
+
content: [{ type: "text", text: clip(parts.join("\n")) }],
|
|
2331
2666
|
details: { status: st, kind: "process", logPath: logPath(params.id) },
|
|
2332
2667
|
};
|
|
2333
2668
|
}
|
|
2334
2669
|
|
|
2335
|
-
// --- subagent: analyze only the current task
|
|
2336
|
-
const
|
|
2337
|
-
if (meta?.promptOffset) logArgs.push("--since", String(meta.promptOffset));
|
|
2338
|
-
const prog = parseEvents((await bs(logArgs)).stdout);
|
|
2670
|
+
// --- subagent: analyze only bytes appended for the current task ---
|
|
2671
|
+
const prog = taskProgressOf(params.id).progress;
|
|
2339
2672
|
const nTools = Math.min(Math.max(1, params.tools ?? 8), 50);
|
|
2340
2673
|
const recent = prog.toolCalls.slice(-nTools);
|
|
2341
2674
|
|
|
@@ -2382,14 +2715,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
2382
2715
|
}
|
|
2383
2716
|
|
|
2384
2717
|
return {
|
|
2385
|
-
content: [{ type: "text", text: parts.join("\n") }],
|
|
2718
|
+
content: [{ type: "text", text: clip(parts.join("\n")) }],
|
|
2386
2719
|
details: { status: st, progress: prog, kind: "subagent" },
|
|
2387
2720
|
};
|
|
2388
2721
|
},
|
|
2389
2722
|
});
|
|
2390
2723
|
|
|
2391
2724
|
// ----- babysit_send -------------------------------------------------------
|
|
2392
|
-
|
|
2725
|
+
registerTool({
|
|
2393
2726
|
name: "babysit_send",
|
|
2394
2727
|
label: "Babysit: send",
|
|
2395
2728
|
description:
|
|
@@ -2484,6 +2817,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
2484
2817
|
details: {},
|
|
2485
2818
|
};
|
|
2486
2819
|
}
|
|
2820
|
+
if (messageNeedsReadTool(params.text) && meta.tools?.length && !meta.tools.includes("read")) {
|
|
2821
|
+
return {
|
|
2822
|
+
content: [
|
|
2823
|
+
{
|
|
2824
|
+
type: "text",
|
|
2825
|
+
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.",
|
|
2826
|
+
},
|
|
2827
|
+
],
|
|
2828
|
+
isError: true,
|
|
2829
|
+
details: {},
|
|
2830
|
+
};
|
|
2831
|
+
}
|
|
2487
2832
|
let mode = params.mode ?? "auto";
|
|
2488
2833
|
if (mode === "auto") {
|
|
2489
2834
|
// isStreaming tells us whether an agent run is in flight right now.
|
|
@@ -2495,12 +2840,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
2495
2840
|
}
|
|
2496
2841
|
mode = streaming ? "steer" : "task";
|
|
2497
2842
|
}
|
|
2843
|
+
const deliveryCleanupAfter =
|
|
2844
|
+
mode === "steer"
|
|
2845
|
+
? (() => {
|
|
2846
|
+
try {
|
|
2847
|
+
return taskProgressOf(params.id).progress.agentEnds + 1;
|
|
2848
|
+
} catch {
|
|
2849
|
+
return 1;
|
|
2850
|
+
}
|
|
2851
|
+
})()
|
|
2852
|
+
: 1;
|
|
2853
|
+
const delivery = deliverableMessage(
|
|
2854
|
+
mode === "steer" ? "steering message" : "task",
|
|
2855
|
+
params.text,
|
|
2856
|
+
);
|
|
2498
2857
|
const cmd =
|
|
2499
2858
|
mode === "steer"
|
|
2500
|
-
? { type: "steer", message:
|
|
2501
|
-
: { type: "prompt", message:
|
|
2859
|
+
? { type: "steer", message: delivery.message }
|
|
2860
|
+
: { type: "prompt", message: delivery.message };
|
|
2502
2861
|
const sent = await sendRpc(params.id, cmd);
|
|
2503
2862
|
if ("error" in sent) {
|
|
2863
|
+
discardDelivery(delivery);
|
|
2504
2864
|
return {
|
|
2505
2865
|
content: [{ type: "text", text: sent.error }],
|
|
2506
2866
|
isError: true,
|
|
@@ -2509,6 +2869,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2509
2869
|
}
|
|
2510
2870
|
const resp = await rpcResponse(params.id, sent.offset, cmd.type, "15s");
|
|
2511
2871
|
if (!resp.ok) {
|
|
2872
|
+
discardDelivery(delivery);
|
|
2512
2873
|
return {
|
|
2513
2874
|
content: [{ type: "text", text: `${cmd.type} was not accepted: ${resp.error}` }],
|
|
2514
2875
|
isError: true,
|
|
@@ -2520,11 +2881,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
2520
2881
|
writeMeta(params.id, {
|
|
2521
2882
|
kind: "subagent",
|
|
2522
2883
|
task: params.text,
|
|
2523
|
-
|
|
2884
|
+
// Start after the prompt-acceptance response. This excludes a
|
|
2885
|
+
// preceding run that settled between `send` and RPC acceptance.
|
|
2886
|
+
promptOffset: resp.offset,
|
|
2524
2887
|
model: meta?.model,
|
|
2888
|
+
tools: meta?.tools,
|
|
2889
|
+
messageTempDirs: delivery.tempDir
|
|
2890
|
+
? [{ dir: delivery.tempDir, afterAgentEnd: deliveryCleanupAfter }]
|
|
2891
|
+
: undefined,
|
|
2525
2892
|
depth: meta?.depth,
|
|
2526
2893
|
maxDepth: meta?.maxDepth,
|
|
2527
2894
|
});
|
|
2895
|
+
} else if (delivery.tempDir && meta) {
|
|
2896
|
+
writeMeta(params.id, {
|
|
2897
|
+
...meta,
|
|
2898
|
+
messageTempDirs: [
|
|
2899
|
+
...(meta.messageTempDirs ?? []),
|
|
2900
|
+
{ dir: delivery.tempDir, afterAgentEnd: deliveryCleanupAfter },
|
|
2901
|
+
],
|
|
2902
|
+
});
|
|
2528
2903
|
}
|
|
2529
2904
|
return {
|
|
2530
2905
|
content: [
|
|
@@ -2542,7 +2917,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2542
2917
|
});
|
|
2543
2918
|
|
|
2544
2919
|
// ----- babysit_wait -------------------------------------------------------
|
|
2545
|
-
|
|
2920
|
+
registerTool({
|
|
2546
2921
|
name: "babysit_wait",
|
|
2547
2922
|
label: "Babysit: wait",
|
|
2548
2923
|
description:
|
|
@@ -2587,6 +2962,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
2587
2962
|
};
|
|
2588
2963
|
}
|
|
2589
2964
|
const limitMs = parseDurMs(params.timeout);
|
|
2965
|
+
if (params.timeout !== undefined && limitMs === null) {
|
|
2966
|
+
throw new Error(
|
|
2967
|
+
`Invalid timeout ${JSON.stringify(params.timeout)}; use an integer with ms, s, m, or h (for example "90s" or "5m").`,
|
|
2968
|
+
);
|
|
2969
|
+
}
|
|
2590
2970
|
|
|
2591
2971
|
if (ids.length === 1) {
|
|
2592
2972
|
const r = await waitFor(ids[0], limitMs, signal, params.expect);
|
|
@@ -2612,7 +2992,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2612
2992
|
content: [
|
|
2613
2993
|
{
|
|
2614
2994
|
type: "text",
|
|
2615
|
-
text:
|
|
2995
|
+
text: clip(
|
|
2996
|
+
results.map((result) => `── ${result.id} [${result.kind}] ──\n${result.text}`).join("\n\n"),
|
|
2997
|
+
ANSWER_MAX_BYTES,
|
|
2998
|
+
),
|
|
2616
2999
|
},
|
|
2617
3000
|
],
|
|
2618
3001
|
isError: !ok,
|
|
@@ -2636,10 +3019,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
2636
3019
|
content: [
|
|
2637
3020
|
{
|
|
2638
3021
|
type: "text",
|
|
2639
|
-
text:
|
|
3022
|
+
text: clip(
|
|
2640
3023
|
`First to finish: ${first.id} [${first.kind}]` +
|
|
2641
|
-
|
|
2642
|
-
|
|
3024
|
+
(others.length ? ` — still waiting-able: ${others.join(", ")}` : "") +
|
|
3025
|
+
`\n\n${first.text}`,
|
|
3026
|
+
ANSWER_MAX_BYTES,
|
|
3027
|
+
),
|
|
2643
3028
|
},
|
|
2644
3029
|
],
|
|
2645
3030
|
isError: !first.ok,
|
|
@@ -2653,7 +3038,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2653
3038
|
});
|
|
2654
3039
|
|
|
2655
3040
|
// ----- babysit_kill -------------------------------------------------------
|
|
2656
|
-
|
|
3041
|
+
registerTool({
|
|
2657
3042
|
name: "babysit_kill",
|
|
2658
3043
|
label: "Babysit: kill",
|
|
2659
3044
|
description: "Terminate a babysit session (process or subagent).",
|
|
@@ -2753,7 +3138,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2753
3138
|
// Inline snapshot (running) or summary (finished) — no tmux window.
|
|
2754
3139
|
if (kind === "subagent") {
|
|
2755
3140
|
// Parse the RPC event stream and show the final answer, not raw JSONL.
|
|
2756
|
-
const prog =
|
|
3141
|
+
const prog = taskProgressOf(picked.id).progress;
|
|
2757
3142
|
const stats =
|
|
2758
3143
|
`turns=${prog.turns} tools=${prog.toolCalls.length}` +
|
|
2759
3144
|
(prog.tokens != null ? ` ctx=${prog.tokens}` : "") +
|