@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.
Files changed (3) hide show
  1. package/README.md +7 -5
  2. package/index.ts +97 -34
  3. 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
- checked by the parent poller. On the first exceeded limit the worker
161
- is steered to stop using tools and return its best answer; if it remains active
162
- after `PI_BABYSIT_BUDGET_GRACE`, termination is verified before the task is
163
- marked budget-killed. Usage shown by check/wait is cumulative for the task.
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` | `30s` | grace after a subagent budget is exceeded before verified termination |
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
- ["expect", "-s", id, "--since", String(since), "--timeout", timeout, `"command":"${command}"`],
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
- const bytes = fs.readFileSync(logPath(id));
814
- structuredError = parseEvents(bytes.subarray(Math.min(since, bytes.length)).toString("utf8")).errorMsg ?? "";
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
- const file = logPath(id);
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 ?? "30s") ?? 30_000;
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({ minimum: 1, description: "Subagent only: maximum turns per task." }),
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({ minimum: 1, description: "Subagent only: maximum tool calls per task." }),
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: "Subagent only: maximum cumulative reported totalTokens across model calls.",
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
4
4
  "description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
5
5
  "keywords": [
6
6
  "pi-package",