@yusukeshib/pi-babysit 0.3.15 → 0.3.17
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 +7 -5
- package/index.ts +97 -34
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -157,10 +157,12 @@ grace window (`PI_BABYSIT_REAP_AFTER`, default 120s) using the same parked-turn
|
|
|
157
157
|
rule, so a subagent waiting on a long build is never false-killed. Give bounded
|
|
158
158
|
recon/review tasks at least one cost, turn, tool-call, or token budget; omit
|
|
159
159
|
budgets only for intentionally open-ended work. Optional task budgets are
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
160
|
+
observed by the parent poller. They are wrap-up thresholds rather than exact
|
|
161
|
+
hard caps: an already in-flight model call or parallel tool batch can overshoot
|
|
162
|
+
before the next poll. On the first observed threshold the worker is steered to
|
|
163
|
+
stop using tools and return its best answer; if it remains active after
|
|
164
|
+
`PI_BABYSIT_BUDGET_GRACE`, termination is verified before the task is marked
|
|
165
|
+
budget-killed. Usage shown by check/wait is cumulative for the task.
|
|
164
166
|
|
|
165
167
|
## Environment overrides
|
|
166
168
|
|
|
@@ -172,7 +174,7 @@ marked budget-killed. Usage shown by check/wait is cumulative for the task.
|
|
|
172
174
|
| `PI_BABYSIT_VIEW_CMD` | bundled `format-stream.mjs` | live-attach pretty printer for subagent JSONL (`""` disables) |
|
|
173
175
|
| `PI_BABYSIT_QUICK_GRACE` | `2s` | interactive process grace before a still-running command is returned as background work; use `foreground: true` to wait explicitly |
|
|
174
176
|
| `PI_BABYSIT_REAP_AFTER` | `120s` | idle grace before a finished subagent self-exits (`off`/`none`/`0` disables) |
|
|
175
|
-
| `PI_BABYSIT_BUDGET_GRACE` | `
|
|
177
|
+
| `PI_BABYSIT_BUDGET_GRACE` | `90s` | grace after a subagent budget is exceeded before verified termination |
|
|
176
178
|
| `PI_BABYSIT_RPC_LOG_MODE` | `compact` | `compact` removes duplicate RPC lifecycle payloads; `standard` opts into legacy payloads |
|
|
177
179
|
| `PI_BABYSIT_RETENTION_DAYS` | unset | when set to a positive number, remove safe terminal roots older than this at session startup |
|
|
178
180
|
| `PI_BABYSIT_TAIL_MAX_BYTES` | `8000` | cap for explicit log tails/screens returned by `babysit_check` |
|
package/index.ts
CHANGED
|
@@ -791,6 +791,33 @@ export function parseRpcResponseBytes(
|
|
|
791
791
|
// it. Distinguishes: success, explicit failure (success:false → the error
|
|
792
792
|
// message, e.g. "No API key found for …"), subagent death, and timeout — this
|
|
793
793
|
// is what makes bad-model/config failures LOUD instead of silent.
|
|
794
|
+
export function rpcResponsePattern(command: string): string {
|
|
795
|
+
const escaped = command.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
796
|
+
// Match only a complete response record. A loose `"command":"…"` can occur
|
|
797
|
+
// in assistant/tool text, and matching before the terminating newline can
|
|
798
|
+
// race the writer while the JSON record is still partial.
|
|
799
|
+
return `(?m)^\\{(?:"id":"[^"\\n]*",)?"type":"response","command":"${escaped}"[^\\n]*\\}\\r?\\n`;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
export function readLogBytesFrom(file: string, since: number): Buffer {
|
|
803
|
+
const size = fs.statSync(file).size;
|
|
804
|
+
const offset = Math.min(Math.max(0, since), size);
|
|
805
|
+
const length = size - offset;
|
|
806
|
+
const bytes = Buffer.allocUnsafe(length);
|
|
807
|
+
const fd = fs.openSync(file, "r");
|
|
808
|
+
let read = 0;
|
|
809
|
+
try {
|
|
810
|
+
while (read < length) {
|
|
811
|
+
const count = fs.readSync(fd, bytes, read, length - read, offset + read);
|
|
812
|
+
if (count === 0) break;
|
|
813
|
+
read += count;
|
|
814
|
+
}
|
|
815
|
+
} finally {
|
|
816
|
+
fs.closeSync(fd);
|
|
817
|
+
}
|
|
818
|
+
return bytes.subarray(0, read);
|
|
819
|
+
}
|
|
820
|
+
|
|
794
821
|
async function rpcResponse(
|
|
795
822
|
id: string,
|
|
796
823
|
since: number,
|
|
@@ -802,7 +829,16 @@ async function rpcResponse(
|
|
|
802
829
|
| { ok: false; error: string }
|
|
803
830
|
> {
|
|
804
831
|
const e = await bs(
|
|
805
|
-
[
|
|
832
|
+
[
|
|
833
|
+
"expect",
|
|
834
|
+
"-s",
|
|
835
|
+
id,
|
|
836
|
+
"--since",
|
|
837
|
+
String(since),
|
|
838
|
+
"--timeout",
|
|
839
|
+
timeout,
|
|
840
|
+
rpcResponsePattern(command),
|
|
841
|
+
],
|
|
806
842
|
{ signal },
|
|
807
843
|
);
|
|
808
844
|
if (e.code !== 0) {
|
|
@@ -810,8 +846,9 @@ async function rpcResponse(
|
|
|
810
846
|
if (st && st.state !== "running") {
|
|
811
847
|
let structuredError = "";
|
|
812
848
|
try {
|
|
813
|
-
|
|
814
|
-
|
|
849
|
+
// Long-lived follow-up workers can have very large historical logs. Read
|
|
850
|
+
// only the response window rather than synchronously loading all history.
|
|
851
|
+
structuredError = parseEvents(readLogBytesFrom(logPath(id), since).toString("utf8")).errorMsg ?? "";
|
|
815
852
|
} catch {
|
|
816
853
|
/* full log path below remains the diagnostic source */
|
|
817
854
|
}
|
|
@@ -832,22 +869,7 @@ async function rpcResponse(
|
|
|
832
869
|
};
|
|
833
870
|
}
|
|
834
871
|
try {
|
|
835
|
-
|
|
836
|
-
const size = fs.statSync(file).size;
|
|
837
|
-
const length = Math.max(0, size - since);
|
|
838
|
-
const bytes = Buffer.allocUnsafe(length);
|
|
839
|
-
const fd = fs.openSync(file, "r");
|
|
840
|
-
let read = 0;
|
|
841
|
-
try {
|
|
842
|
-
while (read < length) {
|
|
843
|
-
const count = fs.readSync(fd, bytes, read, length - read, since + read);
|
|
844
|
-
if (count === 0) break;
|
|
845
|
-
read += count;
|
|
846
|
-
}
|
|
847
|
-
} finally {
|
|
848
|
-
fs.closeSync(fd);
|
|
849
|
-
}
|
|
850
|
-
return parseRpcResponseBytes(bytes.subarray(0, read), since, command);
|
|
872
|
+
return parseRpcResponseBytes(readLogBytesFrom(logPath(id), since), since, command);
|
|
851
873
|
} catch (error) {
|
|
852
874
|
return { ok: false, error: `could not read ${command} response: ${String(error)}` };
|
|
853
875
|
}
|
|
@@ -887,7 +909,7 @@ const NOTIFY_BATCH_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_BATCH_MAX_BYT
|
|
|
887
909
|
const ANSWER_MAX_BYTES = 24_000; // single subagent answers / structured error messages
|
|
888
910
|
const MAX_MULTI_WAIT_SESSIONS = 32;
|
|
889
911
|
const SUBAGENT_BUDGET_GRACE_MS =
|
|
890
|
-
parseDurMs(process.env.PI_BABYSIT_BUDGET_GRACE ?? "
|
|
912
|
+
parseDurMs(process.env.PI_BABYSIT_BUDGET_GRACE ?? "90s") ?? 90_000;
|
|
891
913
|
|
|
892
914
|
export function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
|
|
893
915
|
if (maxBytes <= 0) return "";
|
|
@@ -1285,6 +1307,8 @@ export interface Progress {
|
|
|
1285
1307
|
turns: number;
|
|
1286
1308
|
toolCalls: ToolCall[];
|
|
1287
1309
|
finalText: string;
|
|
1310
|
+
/** Best-effort text from the currently streaming assistant message. */
|
|
1311
|
+
streamingText: string;
|
|
1288
1312
|
/** Context size reported by the most recent assistant response. */
|
|
1289
1313
|
tokens?: number;
|
|
1290
1314
|
/** Cumulative model usage for the current subagent task. */
|
|
@@ -1339,6 +1363,7 @@ function emptyProgress(): Progress {
|
|
|
1339
1363
|
turns: 0,
|
|
1340
1364
|
toolCalls: [],
|
|
1341
1365
|
finalText: "",
|
|
1366
|
+
streamingText: "",
|
|
1342
1367
|
modelCalls: 0,
|
|
1343
1368
|
usageTokens: 0,
|
|
1344
1369
|
inputTokens: 0,
|
|
@@ -1417,7 +1442,17 @@ function parseEventLine(progress: Progress, raw: string): void {
|
|
|
1417
1442
|
switch (event.type) {
|
|
1418
1443
|
case "turn_start":
|
|
1419
1444
|
progress.turns++;
|
|
1445
|
+
progress.streamingText = "";
|
|
1446
|
+
break;
|
|
1447
|
+
case "message_update": {
|
|
1448
|
+
const update = event.assistantMessageEvent as
|
|
1449
|
+
| { type?: string; delta?: string }
|
|
1450
|
+
| undefined;
|
|
1451
|
+
if (update?.type === "text_delta" && typeof update.delta === "string") {
|
|
1452
|
+
progress.streamingText += update.delta;
|
|
1453
|
+
}
|
|
1420
1454
|
break;
|
|
1455
|
+
}
|
|
1421
1456
|
case "tool_execution_start": {
|
|
1422
1457
|
const name = String(event.toolName ?? "tool");
|
|
1423
1458
|
progress.toolCalls.push({
|
|
@@ -1448,6 +1483,7 @@ function parseEventLine(progress: Progress, raw: string): void {
|
|
|
1448
1483
|
.map((content) => content.text)
|
|
1449
1484
|
.join("");
|
|
1450
1485
|
if (text.trim()) progress.finalText = text;
|
|
1486
|
+
progress.streamingText = "";
|
|
1451
1487
|
if (message.usage) {
|
|
1452
1488
|
const finite = (value: number | undefined) =>
|
|
1453
1489
|
typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
@@ -1531,6 +1567,7 @@ export function buildSubagentExitDiagnostic(
|
|
|
1531
1567
|
): string {
|
|
1532
1568
|
const body = clip(
|
|
1533
1569
|
progress.errorMsg ||
|
|
1570
|
+
progress.streamingText.trim() ||
|
|
1534
1571
|
progress.finalText.trim() ||
|
|
1535
1572
|
"(no structured error was emitted; inspect the full log)",
|
|
1536
1573
|
ANSWER_MAX_BYTES,
|
|
@@ -1546,6 +1583,22 @@ interface TaskProgressCache {
|
|
|
1546
1583
|
}
|
|
1547
1584
|
const taskProgressCache = new Map<string, TaskProgressCache>();
|
|
1548
1585
|
|
|
1586
|
+
export function pruneTerminalSessionCache<T>(
|
|
1587
|
+
cache: Map<string, T>,
|
|
1588
|
+
sessions: Array<{ id: string; state: string }>,
|
|
1589
|
+
): number {
|
|
1590
|
+
const running = new Set(
|
|
1591
|
+
sessions.filter((session) => session.state === "running").map((session) => session.id),
|
|
1592
|
+
);
|
|
1593
|
+
let removed = 0;
|
|
1594
|
+
for (const id of cache.keys()) {
|
|
1595
|
+
if (running.has(id)) continue;
|
|
1596
|
+
cache.delete(id);
|
|
1597
|
+
removed++;
|
|
1598
|
+
}
|
|
1599
|
+
return removed;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1549
1602
|
/** Parse only bytes appended since the previous observation of this task. */
|
|
1550
1603
|
function taskProgressOf(id: string): { progress: Progress; offset: number } {
|
|
1551
1604
|
const base = readMeta(id)?.promptOffset ?? 0;
|
|
@@ -2409,16 +2462,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
2409
2462
|
for (const session of sessions) {
|
|
2410
2463
|
if (session.state !== "running") continue;
|
|
2411
2464
|
await withSessionRpcLock(session.id, async () => {
|
|
2412
|
-
const latestStatus = await statusOf(session.id);
|
|
2413
2465
|
const meta = readMeta(session.id);
|
|
2414
|
-
if (
|
|
2415
|
-
latestStatus?.state !== "running" ||
|
|
2416
|
-
meta?.kind !== "subagent" ||
|
|
2417
|
-
!meta.budget ||
|
|
2418
|
-
meta.budgetKilled
|
|
2419
|
-
) {
|
|
2420
|
-
return;
|
|
2421
|
-
}
|
|
2466
|
+
if (meta?.kind !== "subagent" || !meta.budget || meta.budgetKilled) return;
|
|
2422
2467
|
|
|
2423
2468
|
let progress: Progress;
|
|
2424
2469
|
try {
|
|
@@ -2438,6 +2483,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
2438
2483
|
if (decision.action === "none") return;
|
|
2439
2484
|
const reason = decision.reason as string;
|
|
2440
2485
|
|
|
2486
|
+
// The poll already supplied a running-session snapshot. Revalidate only
|
|
2487
|
+
// when a limit requires action; checking every healthy worker made each
|
|
2488
|
+
// interval launch N extra `babysit list` subprocesses.
|
|
2489
|
+
const latestStatus = await statusOf(session.id);
|
|
2490
|
+
if (latestStatus?.state !== "running") return;
|
|
2491
|
+
|
|
2441
2492
|
if (decision.action === "steer") {
|
|
2442
2493
|
// Start the grace period only after Pi accepts the steering command.
|
|
2443
2494
|
const sent = await sendRpc(session.id, {
|
|
@@ -2525,7 +2576,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2525
2576
|
: "failed";
|
|
2526
2577
|
const output = await inlineOutput(session.id, session, NOTIFY_OUTPUT_MAX_BYTES);
|
|
2527
2578
|
const runtime = meta.startedAt
|
|
2528
|
-
? `${Math.round((Date.now() - meta.startedAt) / 1000)}s`
|
|
2579
|
+
? `${Math.round(((meta.completionObservedAt ?? Date.now()) - meta.startedAt) / 1000)}s`
|
|
2529
2580
|
: "?";
|
|
2530
2581
|
const summary = ok
|
|
2531
2582
|
? `Process "${session.id}" completed successfully after ${runtime}.`
|
|
@@ -2545,6 +2596,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
2545
2596
|
});
|
|
2546
2597
|
}
|
|
2547
2598
|
|
|
2599
|
+
if (prepared.length === 0) return;
|
|
2600
|
+
|
|
2548
2601
|
// Output loading above is asynchronous. Refresh sessions and metadata
|
|
2549
2602
|
// immediately before the single send so concurrent wait/kill calls and newly
|
|
2550
2603
|
// started notification-group members are honored.
|
|
@@ -2763,6 +2816,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2763
2816
|
notifyEndedProcesses(ctx, snapshot),
|
|
2764
2817
|
refreshWidget(ctx, snapshot),
|
|
2765
2818
|
]);
|
|
2819
|
+
pruneTerminalSessionCache(taskProgressCache, snapshot);
|
|
2766
2820
|
pollNeeded = shouldKeepPolling(snapshot, readMeta);
|
|
2767
2821
|
})()
|
|
2768
2822
|
.catch(() => {
|
|
@@ -2896,15 +2950,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
2896
2950
|
}),
|
|
2897
2951
|
),
|
|
2898
2952
|
maxTurns: Type.Optional(
|
|
2899
|
-
Type.Integer({
|
|
2953
|
+
Type.Integer({
|
|
2954
|
+
minimum: 1,
|
|
2955
|
+
description:
|
|
2956
|
+
"Subagent only: observed turn threshold for steering it to wrap up. In-flight work can overshoot before the poller intervenes.",
|
|
2957
|
+
}),
|
|
2900
2958
|
),
|
|
2901
2959
|
maxToolCalls: Type.Optional(
|
|
2902
|
-
Type.Integer({
|
|
2960
|
+
Type.Integer({
|
|
2961
|
+
minimum: 1,
|
|
2962
|
+
description:
|
|
2963
|
+
"Subagent only: observed tool-call threshold for steering it to wrap up. A parallel in-flight tool batch can overshoot.",
|
|
2964
|
+
}),
|
|
2903
2965
|
),
|
|
2904
2966
|
maxUsageTokens: Type.Optional(
|
|
2905
2967
|
Type.Integer({
|
|
2906
2968
|
minimum: 1,
|
|
2907
|
-
description:
|
|
2969
|
+
description:
|
|
2970
|
+
"Subagent only: observed cumulative reported totalTokens threshold for steering it to wrap up.",
|
|
2908
2971
|
}),
|
|
2909
2972
|
),
|
|
2910
2973
|
agentScope: Type.Optional(
|
|
@@ -3436,7 +3499,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3436
3499
|
if (el) header += ` elapsed=${el}`;
|
|
3437
3500
|
}
|
|
3438
3501
|
if (st.exit_code != null) header += ` exit_code=${st.exit_code}`;
|
|
3439
|
-
if (meta?.command) header += `\ncommand: ${meta.command}`;
|
|
3502
|
+
if (meta?.command) header += `\ncommand: ${summarizeNotificationCommand(meta.command)}`;
|
|
3440
3503
|
header += `\nlog: ${logPath(params.id)}`;
|
|
3441
3504
|
if (st.note) header += ` ⚑ ${st.note}`;
|
|
3442
3505
|
parts.push(header);
|