@yusukeshib/pi-babysit 0.3.16 → 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 +79 -33
  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 "";
@@ -1561,6 +1583,22 @@ interface TaskProgressCache {
1561
1583
  }
1562
1584
  const taskProgressCache = new Map<string, TaskProgressCache>();
1563
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
+
1564
1602
  /** Parse only bytes appended since the previous observation of this task. */
1565
1603
  function taskProgressOf(id: string): { progress: Progress; offset: number } {
1566
1604
  const base = readMeta(id)?.promptOffset ?? 0;
@@ -2424,16 +2462,8 @@ export default function (pi: ExtensionAPI) {
2424
2462
  for (const session of sessions) {
2425
2463
  if (session.state !== "running") continue;
2426
2464
  await withSessionRpcLock(session.id, async () => {
2427
- const latestStatus = await statusOf(session.id);
2428
2465
  const meta = readMeta(session.id);
2429
- if (
2430
- latestStatus?.state !== "running" ||
2431
- meta?.kind !== "subagent" ||
2432
- !meta.budget ||
2433
- meta.budgetKilled
2434
- ) {
2435
- return;
2436
- }
2466
+ if (meta?.kind !== "subagent" || !meta.budget || meta.budgetKilled) return;
2437
2467
 
2438
2468
  let progress: Progress;
2439
2469
  try {
@@ -2453,6 +2483,12 @@ export default function (pi: ExtensionAPI) {
2453
2483
  if (decision.action === "none") return;
2454
2484
  const reason = decision.reason as string;
2455
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
+
2456
2492
  if (decision.action === "steer") {
2457
2493
  // Start the grace period only after Pi accepts the steering command.
2458
2494
  const sent = await sendRpc(session.id, {
@@ -2780,6 +2816,7 @@ export default function (pi: ExtensionAPI) {
2780
2816
  notifyEndedProcesses(ctx, snapshot),
2781
2817
  refreshWidget(ctx, snapshot),
2782
2818
  ]);
2819
+ pruneTerminalSessionCache(taskProgressCache, snapshot);
2783
2820
  pollNeeded = shouldKeepPolling(snapshot, readMeta);
2784
2821
  })()
2785
2822
  .catch(() => {
@@ -2913,15 +2950,24 @@ export default function (pi: ExtensionAPI) {
2913
2950
  }),
2914
2951
  ),
2915
2952
  maxTurns: Type.Optional(
2916
- 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
+ }),
2917
2958
  ),
2918
2959
  maxToolCalls: Type.Optional(
2919
- 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
+ }),
2920
2965
  ),
2921
2966
  maxUsageTokens: Type.Optional(
2922
2967
  Type.Integer({
2923
2968
  minimum: 1,
2924
- 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.",
2925
2971
  }),
2926
2972
  ),
2927
2973
  agentScope: Type.Optional(
@@ -3453,7 +3499,7 @@ export default function (pi: ExtensionAPI) {
3453
3499
  if (el) header += ` elapsed=${el}`;
3454
3500
  }
3455
3501
  if (st.exit_code != null) header += ` exit_code=${st.exit_code}`;
3456
- if (meta?.command) header += `\ncommand: ${meta.command}`;
3502
+ if (meta?.command) header += `\ncommand: ${summarizeNotificationCommand(meta.command)}`;
3457
3503
  header += `\nlog: ${logPath(params.id)}`;
3458
3504
  if (st.note) header += ` ⚑ ${st.note}`;
3459
3505
  parts.push(header);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.3.16",
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",