@yusukeshib/pi-babysit 0.3.11 → 0.3.12

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 +17 -9
  2. package/index.ts +140 -33
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -62,8 +62,11 @@ programs** (installers, wizards, REPLs): type with `babysit_send`
62
62
  | `babysit_wait` | Block until done: process exit (or `expect: "regex"` readiness marker), subagent task completion. Multi-wait: `ids` + `mode: "any"\|"all"` |
63
63
  | `babysit_kill` | Terminate a session, verify terminal state, then suppress the exit notification |
64
64
 
65
- A `tool_call` hook blocks shell backgrounding (`… &`, `nohup`, `setsid`,
66
- `disown`) and redirects all direct `bash` commands to `babysit_run`.
65
+ The built-in `bash` tool is removed from the active tool set so the model does
66
+ not waste a failed tool turn before choosing `babysit_run`. A fallback
67
+ `tool_call` hook still blocks direct shell calls if another extension or preset
68
+ re-enables `bash`, including shell backgrounding (`… &`, `nohup`, `setsid`,
69
+ `disown`). Set `PI_BABYSIT_ALLOW_BASH=1` to retain direct `bash` explicitly.
67
70
 
68
71
  ## Commands (human)
69
72
 
@@ -91,9 +94,10 @@ babysit_check { id: "cargo-test", lines: 50 }
91
94
  babysit_check { id: "cargo-test", pattern: "FAIL|ERROR", lines: 50 }
92
95
  ```
93
96
 
94
- Tail and search results are capped at 200 lines and clipped to 8 KB. Pattern
95
- search returns the latest matching lines with line numbers. Do not read a
96
- potentially large log file in full.
97
+ Tail and search results are capped at 200 lines, and the complete returned tool
98
+ result (including lifecycle headers) is clipped to 8 KB. Pattern search returns
99
+ the latest matching lines with line numbers. Prefer a targeted pattern over a
100
+ broad tail, and do not read a potentially large log file in full.
97
101
 
98
102
  Subagent logs also compact Pi's streaming `message_update` events before they
99
103
  are recorded. Pi repeats the complete growing assistant message and partial
@@ -103,7 +107,8 @@ events untouched. This keeps long RPC sessions approximately linear in emitted
103
107
  content without changing final answers, completion detection, or follow-up
104
108
  behavior. Live `/babysit` and attach views render the retained deltas.
105
109
 
106
- All shell commands, including `pwd` and Git, are redirected to `babysit_run`.
110
+ All shell commands, including `pwd` and Git, go through `babysit_run`. Bundle
111
+ closely related tiny observations when doing so safely reduces tool turns.
107
112
  Set `PI_BABYSIT_ALLOW_BASH=1` only as an explicit emergency escape hatch.
108
113
 
109
114
  ## Unexpected worker loss
@@ -117,9 +122,12 @@ because blindly rerunning an arbitrary command can duplicate side effects.
117
122
 
118
123
  ## How completion detection works
119
124
 
120
- - **Process**: a 2.5s poller watches for running→exited transitions and injects
121
- one `pi.sendMessage(…, { triggerTurn: true, deliverAs: "steer" })` containing
122
- every deliverable exit observed in that poll (deduped via `meta/<id>.json`).
125
+ - **Process**: a 2.5s poller watches for running→exited transitions and, once the
126
+ parent agent is idle, injects one
127
+ `pi.sendMessage(…, { triggerTurn: true, deliverAs: "steer" })` containing every
128
+ deliverable exit observed in that poll (deduped via `meta/<id>.json`). Waiting
129
+ for idleness prevents an immediately-following `babysit_wait` from racing the
130
+ poller and receiving a duplicate completion.
123
131
  `babysit_kill` and an exit already reported by `babysit_wait` suppress the
124
132
  notification.
125
133
  - **Subagent**: `babysit_wait` blocks on `babysit expect '"type":"agent_end"'`.
package/index.ts CHANGED
@@ -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 { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
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
 
@@ -365,6 +370,10 @@ interface Meta {
365
370
  // Temporary reservation while kill is in flight. Unlike `notified`, this
366
371
  // must be cleared on failure so a real completion remains deliverable.
367
372
  notificationPaused?: boolean;
373
+ // Concurrent explicit waits share a reference-counted notification claim.
374
+ // A timeout must not re-enable the poller while another wait still owns it.
375
+ waitReservations?: number;
376
+ waitCompletionClaimed?: boolean;
368
377
  completionObservedAt?: number;
369
378
  startedAt?: number;
370
379
  // subagent
@@ -405,6 +414,10 @@ export function shouldDeliverProcessCompletion(
405
414
  return meta?.kind === "process" && !meta.notified && !meta.notificationPaused;
406
415
  }
407
416
 
417
+ export function shouldDeferCompletionNotification(agentIsIdle: boolean): boolean {
418
+ return !agentIsIdle;
419
+ }
420
+
408
421
  const kindOf = (id: string): "process" | "subagent" => readMeta(id)?.kind ?? "process";
409
422
 
410
423
  // Compact elapsed formatting: "42s", "3m12s", "1h04m".
@@ -528,14 +541,26 @@ const NOTIFY_COMMAND_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_COMMAND_MAX
528
541
  const NOTIFY_BATCH_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_BATCH_MAX_BYTES", 8_000);
529
542
  const ANSWER_MAX_BYTES = 24_000; // subagent answers / error messages
530
543
 
531
- function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
544
+ export function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
545
+ if (maxBytes <= 0) return "";
532
546
  const buf = Buffer.from(s, "utf8");
533
547
  if (buf.length <= maxBytes) return s;
534
- const half = Math.floor(maxBytes / 2);
535
- // Strip replacement chars from a mid-codepoint cut at the boundary.
536
- const head = buf.subarray(0, half).toString("utf8").replace(/\uFFFD+$/, "");
537
- const tail = buf.subarray(buf.length - half).toString("utf8").replace(/^\uFFFD+/, "");
538
- return `${head}\n… [${buf.length - maxBytes} bytes elided] …\n${tail}`;
548
+
549
+ // The marker counts toward the limit. Recompute a few times because the
550
+ // omitted-byte count can change the marker's digit width.
551
+ let available = maxBytes;
552
+ let marker = "";
553
+ for (let i = 0; i < 3; i++) {
554
+ marker = `\n… [${buf.length - available} bytes elided] …\n`;
555
+ available = Math.max(0, maxBytes - Buffer.byteLength(marker, "utf8"));
556
+ }
557
+ if (Buffer.byteLength(marker, "utf8") > maxBytes) return truncateUtf8End(marker, maxBytes);
558
+ const headBytes = Math.floor(available / 2);
559
+ const tailBytes = available - headBytes;
560
+ // Strip replacement chars from a mid-codepoint cut at either boundary.
561
+ const head = buf.subarray(0, headBytes).toString("utf8").replace(/\uFFFD+$/, "");
562
+ const tail = buf.subarray(buf.length - tailBytes).toString("utf8").replace(/^\uFFFD+/, "");
563
+ return `${head}${marker}${tail}`;
539
564
  }
540
565
 
541
566
  async function searchLog(
@@ -1442,19 +1467,47 @@ function suppressNotify(id: string, reason: "observed" | "kill" = "observed"): v
1442
1467
  }
1443
1468
  }
1444
1469
 
1445
- export function canRestoreNotificationAfterWait(meta: {
1470
+ export interface WaitReservationState {
1446
1471
  notified?: boolean;
1447
1472
  killNotificationSuppressed?: boolean;
1448
- }): boolean {
1449
- return meta.notified === true && meta.killNotificationSuppressed !== true;
1473
+ waitReservations?: number;
1474
+ waitCompletionClaimed?: boolean;
1450
1475
  }
1451
1476
 
1452
- function enableNotify(id: string): void {
1453
- const meta = readMeta(id);
1454
- if (meta && meta.kind === "process" && canRestoreNotificationAfterWait(meta)) {
1455
- meta.notified = false;
1456
- writeMeta(id, meta);
1477
+ export function canRestoreNotificationAfterWait(meta: WaitReservationState): boolean {
1478
+ return (
1479
+ meta.notified === true &&
1480
+ meta.killNotificationSuppressed !== true &&
1481
+ meta.waitCompletionClaimed !== true &&
1482
+ (meta.waitReservations ?? 0) === 0
1483
+ );
1484
+ }
1485
+
1486
+ export function transitionWaitReservation(
1487
+ state: WaitReservationState,
1488
+ action: "reserve" | "abandon" | "claim",
1489
+ ): WaitReservationState {
1490
+ const next = { ...state };
1491
+ if (action === "reserve") {
1492
+ next.waitReservations = (next.waitReservations ?? 0) + 1;
1493
+ next.notified = true;
1494
+ return next;
1457
1495
  }
1496
+
1497
+ next.waitReservations = Math.max(0, (next.waitReservations ?? 0) - 1);
1498
+ if (action === "claim") next.waitCompletionClaimed = true;
1499
+ if (action === "claim" || next.waitCompletionClaimed) {
1500
+ next.notified = true;
1501
+ } else if (canRestoreNotificationAfterWait(next)) {
1502
+ next.notified = false;
1503
+ }
1504
+ return next;
1505
+ }
1506
+
1507
+ function updateWaitReservation(id: string, action: "reserve" | "abandon" | "claim"): void {
1508
+ const meta = readMeta(id);
1509
+ if (!meta || meta.kind !== "process") return;
1510
+ writeMeta(id, { ...meta, ...transitionWaitReservation(meta, action) });
1458
1511
  }
1459
1512
 
1460
1513
  function pauseNotify(id: string): void {
@@ -1508,19 +1561,20 @@ async function waitForExit(
1508
1561
  }
1509
1562
  // fall through: session exited before the pattern appeared
1510
1563
  } else {
1511
- // An explicit wait owns completion delivery. Mark it before blocking so
1512
- // the exit poller cannot race us and inject a duplicate notification.
1513
- suppressNotify(id);
1564
+ // An explicit wait owns completion delivery. Reference-count the claim so
1565
+ // one concurrent wait timing out cannot re-enable notifications underneath
1566
+ // another wait that is still pending.
1567
+ updateWaitReservation(id, "reserve");
1514
1568
  const w = await bs(["wait", "-s", id, "--timeout", t], { signal });
1515
1569
  if (signal?.aborted || w.code === 130) {
1516
- enableNotify(id);
1570
+ updateWaitReservation(id, "abandon");
1517
1571
  return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
1518
1572
  }
1519
1573
  if (w.code === 124) {
1520
1574
  // 124 is ambiguous (timeout vs child exiting 124) — disambiguate.
1521
1575
  const st0 = await statusOf(id);
1522
1576
  if (st0?.state === "running") {
1523
- enableNotify(id);
1577
+ updateWaitReservation(id, "abandon");
1524
1578
  return {
1525
1579
  id,
1526
1580
  kind: "timeout",
@@ -1534,9 +1588,15 @@ async function waitForExit(
1534
1588
 
1535
1589
  const st = await statusOf(id);
1536
1590
  if (!st) {
1591
+ // A non-pattern backend wait returned before this lookup, so preserve its
1592
+ // completion claim when status persistence is transiently unavailable.
1593
+ // Abandoning here can re-enable the poller and duplicate a completion the
1594
+ // explicit wait already consumed. This matches the old suppress-first rule.
1595
+ if (!expectPattern) updateWaitReservation(id, "claim");
1537
1596
  return { id, kind: "exited", ok: false, text: `No such session: ${id}` };
1538
1597
  }
1539
- suppressNotify(id); // the agent sees the exit here; don't notify again
1598
+ if (expectPattern) suppressNotify(id);
1599
+ else updateWaitReservation(id, "claim"); // the agent sees the exit here; don't notify again
1540
1600
  const meta = readMeta(id);
1541
1601
  const workerDead = st.state === "dead" && st.exit_code == null;
1542
1602
  const ok = st.exit_code === 0;
@@ -1588,12 +1648,41 @@ export function isAllowedDirectBash(_command: string): boolean {
1588
1648
  return process.env.PI_BABYSIT_ALLOW_BASH === "1";
1589
1649
  }
1590
1650
 
1651
+ export function activeToolsWithoutDirectBash(activeTools: string[], allowDirectBash: boolean): string[] {
1652
+ return allowDirectBash ? activeTools : activeTools.filter((name) => name !== "bash");
1653
+ }
1654
+
1591
1655
  // ---------------------------------------------------------------------------
1592
1656
  // extension
1593
1657
  // ---------------------------------------------------------------------------
1594
1658
 
1595
1659
  export default function (pi: ExtensionAPI) {
1596
1660
  let pollTimer: ReturnType<typeof setInterval> | undefined;
1661
+ const declaredToolErrors = new Set<string>();
1662
+
1663
+ // Pi only persists custom-tool failures when they are thrown or patched by a
1664
+ // tool_result hook; an `isError` property returned from execute() is ignored.
1665
+ // Keep the structured result (status, log path, diagnostics) while promoting
1666
+ // its declared error bit at the supported hook boundary.
1667
+ const registerTool = <TParams extends TSchema, TDetails, TState>(
1668
+ tool: ToolDefinition<TParams, TDetails, TState>,
1669
+ ): void => {
1670
+ const execute = tool.execute;
1671
+ pi.registerTool({
1672
+ ...tool,
1673
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
1674
+ const result = await execute(toolCallId, params, signal, onUpdate, ctx);
1675
+ if ((result as typeof result & { isError?: boolean }).isError === true) {
1676
+ declaredToolErrors.add(toolCallId);
1677
+ }
1678
+ return result;
1679
+ },
1680
+ });
1681
+ };
1682
+
1683
+ pi.on("tool_result", (event) => {
1684
+ if (declaredToolErrors.delete(event.toolCallId)) return { isError: true };
1685
+ });
1597
1686
 
1598
1687
  // Exit notifications for kind=process sessions: the poller detects
1599
1688
  // running→exited transitions and injects ONE message (triggerTurn) for all
@@ -1601,7 +1690,11 @@ export default function (pi: ExtensionAPI) {
1601
1690
  // that ended its turn after babysit_run without spending one turn per exit.
1602
1691
  // Kills via babysit_kill and exits already reported by babysit_wait are
1603
1692
  // suppressed via meta.notified.
1604
- async function notifyEndedProcesses(): Promise<void> {
1693
+ async function notifyEndedProcesses(ctx: ExtensionContext): Promise<void> {
1694
+ // Never steer a completion into an active agent turn. In particular, this
1695
+ // lets an immediately-following babysit_wait reserve the completion first,
1696
+ // instead of racing the poller and receiving both wait + auto notification.
1697
+ if (shouldDeferCompletionNotification(ctx.isIdle())) return;
1605
1698
  const { sessions } = await listSessions();
1606
1699
  const ready: Array<{ session: BsSession; meta: Meta }> = [];
1607
1700
  for (const session of sessions) {
@@ -1660,6 +1753,9 @@ export default function (pi: ExtensionAPI) {
1660
1753
  metadataById.set(notice.id, current);
1661
1754
  return [{ ...notice, command: current.command }];
1662
1755
  });
1756
+ // Output collection above yields to the event loop. Re-check idleness so a
1757
+ // newly-started agent turn cannot receive a duplicate completion mid-turn.
1758
+ if (shouldDeferCompletionNotification(ctx.isIdle())) return;
1663
1759
  deliverProcessCompletionMessage(
1664
1760
  notices,
1665
1761
  (message, options) => pi.sendMessage(message, options),
@@ -1773,6 +1869,16 @@ export default function (pi: ExtensionAPI) {
1773
1869
 
1774
1870
  let polling = false;
1775
1871
  pi.on("session_start", async (_event, ctx) => {
1872
+ // Do not expose the built-in bash tool only to reject it after the model has
1873
+ // already paid for a failed tool turn. The tool_call hook remains a fallback
1874
+ // if another extension/preset re-enables bash later in the session.
1875
+ const activeTools = pi.getActiveTools();
1876
+ const supervisedTools = activeToolsWithoutDirectBash(
1877
+ activeTools,
1878
+ isAllowedDirectBash(""),
1879
+ );
1880
+ if (supervisedTools.length !== activeTools.length) pi.setActiveTools(supervisedTools);
1881
+
1776
1882
  // Session-local registry: scope the babysit root to this pi session so
1777
1883
  // other sessions' processes/subagents are invisible here. Resuming a
1778
1884
  // session keeps the same id, so its sessions come back with it.
@@ -1792,7 +1898,7 @@ export default function (pi: ExtensionAPI) {
1792
1898
  // calls never stack up.
1793
1899
  if (polling) return;
1794
1900
  polling = true;
1795
- Promise.all([notifyEndedProcesses(), refreshWidget(ctx)])
1901
+ Promise.all([notifyEndedProcesses(ctx), refreshWidget(ctx)])
1796
1902
  .catch(() => {
1797
1903
  /* ignore poll errors */
1798
1904
  })
@@ -1829,7 +1935,7 @@ export default function (pi: ExtensionAPI) {
1829
1935
  });
1830
1936
 
1831
1937
  // ----- babysit_run --------------------------------------------------------
1832
- pi.registerTool({
1938
+ registerTool({
1833
1939
  name: "babysit_run",
1834
1940
  label: "Babysit: run",
1835
1941
  description:
@@ -1850,7 +1956,8 @@ export default function (pi: ExtensionAPI) {
1850
1956
  "Run any shell command with context-safe captured output; quick commands return metadata, longer ones continue in background",
1851
1957
  promptGuidelines: [
1852
1958
  "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
- "Inspect a babysit log with babysit_check { id, lines, pattern? }; never read or cat a potentially large log file in full.",
1959
+ "Bundle closely related tiny observations into one babysit_run command when that reduces tool turns without obscuring lifecycle or failure handling.",
1960
+ "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
1961
  "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
1962
  "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
1963
  "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 }.",
@@ -2199,7 +2306,7 @@ export default function (pi: ExtensionAPI) {
2199
2306
  });
2200
2307
 
2201
2308
  // ----- babysit_check ------------------------------------------------------
2202
- pi.registerTool({
2309
+ registerTool({
2203
2310
  name: "babysit_check",
2204
2311
  label: "Babysit: check",
2205
2312
  description:
@@ -2257,7 +2364,7 @@ export default function (pi: ExtensionAPI) {
2257
2364
  const preview = what.length > 60 ? `${what.slice(0, 57)}…` : what;
2258
2365
  return `${s.id} [${kind}] ${s.state}${ec}${depth}${flag}${preview ? ` — ${preview}` : ""}`;
2259
2366
  });
2260
- return { content: [{ type: "text", text: lines.join("\n") }], details: { sessions } };
2367
+ return { content: [{ type: "text", text: clip(lines.join("\n")) }], details: { sessions } };
2261
2368
  }
2262
2369
 
2263
2370
  const st = await statusOf(params.id);
@@ -2299,7 +2406,7 @@ export default function (pi: ExtensionAPI) {
2299
2406
  ? `--- latest matches /${params.pattern}/ ---\n${result.text}`
2300
2407
  : `(no output matching /${params.pattern}/)`;
2301
2408
  return {
2302
- content: [{ type: "text", text: `${header}\n${body}` }],
2409
+ content: [{ type: "text", text: clip(`${header}\n${body}`) }],
2303
2410
  details: { status: st, kind, logPath: logPath(params.id), pattern: params.pattern },
2304
2411
  };
2305
2412
  }
@@ -2327,7 +2434,7 @@ export default function (pi: ExtensionAPI) {
2327
2434
  parts.push(tail ? `--- recent output ---\n${tail}` : "(no output yet)");
2328
2435
  }
2329
2436
  return {
2330
- content: [{ type: "text", text: parts.join("\n") }],
2437
+ content: [{ type: "text", text: clip(parts.join("\n")) }],
2331
2438
  details: { status: st, kind: "process", logPath: logPath(params.id) },
2332
2439
  };
2333
2440
  }
@@ -2389,7 +2496,7 @@ export default function (pi: ExtensionAPI) {
2389
2496
  });
2390
2497
 
2391
2498
  // ----- babysit_send -------------------------------------------------------
2392
- pi.registerTool({
2499
+ registerTool({
2393
2500
  name: "babysit_send",
2394
2501
  label: "Babysit: send",
2395
2502
  description:
@@ -2542,7 +2649,7 @@ export default function (pi: ExtensionAPI) {
2542
2649
  });
2543
2650
 
2544
2651
  // ----- babysit_wait -------------------------------------------------------
2545
- pi.registerTool({
2652
+ registerTool({
2546
2653
  name: "babysit_wait",
2547
2654
  label: "Babysit: wait",
2548
2655
  description:
@@ -2653,7 +2760,7 @@ export default function (pi: ExtensionAPI) {
2653
2760
  });
2654
2761
 
2655
2762
  // ----- babysit_kill -------------------------------------------------------
2656
- pi.registerTool({
2763
+ registerTool({
2657
2764
  name: "babysit_kill",
2658
2765
  label: "Babysit: kill",
2659
2766
  description: "Terminate a babysit session (process or subagent).",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
4
4
  "description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
5
5
  "keywords": [
6
6
  "pi-package",