@yusukeshib/pi-babysit 0.3.4 → 0.3.7

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 CHANGED
@@ -13,8 +13,8 @@ It retires both `@mjakl/pi-processes` (the `process` tool) and the old
13
13
  pi install npm:@yusukeshib/pi-babysit
14
14
  ```
15
15
 
16
- Then install the [`babysit`](https://github.com/yusukeshib/babysit) binary
17
- (the extension does **not** auto-install it):
16
+ Then install [`babysit`](https://github.com/yusukeshib/babysit) **0.13.0 or
17
+ newer** (the extension does **not** auto-install it):
18
18
 
19
19
  ```sh
20
20
  cargo install --git https://github.com/yusukeshib/babysit
@@ -22,8 +22,8 @@ cargo install --git https://github.com/yusukeshib/babysit
22
22
 
23
23
  or grab a prebuilt binary from the
24
24
  [releases](https://github.com/yusukeshib/babysit/releases) and put it on your
25
- `PATH`. If it's missing, every tool and the `/babysit` command fail with these
26
- instructions.
25
+ `PATH`. If it is missing or older than 0.13.0, every tool and the `/babysit`
26
+ command fail with upgrade instructions.
27
27
 
28
28
  ## The model
29
29
 
@@ -50,14 +50,13 @@ programs** (installers, wizards, REPLs): type with `babysit_send`
50
50
  | Tool | What it does |
51
51
  | ---- | ------------ |
52
52
  | `babysit_run` | Run any command (`command`, optional `name`/`pty`/`timeout`/`idleTimeout`/`retryOnWorkerDeath`) or start a subagent (`profile: "subagent"`, `task`, optional `agent`/`model`/`tools`). Quick commands return inline; longer ones continue in the background |
53
- | `babysit_check` | List all sessions, or inspect one: process → state + log tail (or `screen: true` for TUIs); subagent → live progress (turns, recent tool calls, partial answer) |
53
+ | `babysit_check` | List all sessions, inspect one, tail its bounded recent output, or search its raw log with `pattern`; `screen: true` captures TUIs and subagents otherwise show structured live progress |
54
54
  | `babysit_send` | Process: type `text` / press `keys` into the PTY. Subagent: steer mid-run, or send a follow-up task when idle (`mode: auto/steer/task`) |
55
55
  | `babysit_wait` | Block until done: process exit (or `expect: "regex"` readiness marker), subagent task completion. Multi-wait: `ids` + `mode: "any"\|"all"` |
56
- | `babysit_kill` | Terminate a session (suppresses the exit notification) |
56
+ | `babysit_kill` | Terminate a session, verify terminal state, then suppress the exit notification |
57
57
 
58
- A `tool_call` hook also blocks bash commands that background themselves
59
- (`… &`, `nohup`, `setsid`, `disown`) and points the agent at `babysit_run`
60
- (carried over from pi-processes' `blockBackgroundCommands`).
58
+ A `tool_call` hook blocks shell backgrounding (`… &`, `nohup`, `setsid`,
59
+ `disown`) and redirects all direct `bash` commands to `babysit_run`.
61
60
 
62
61
  ## Commands (human)
63
62
 
@@ -72,33 +71,31 @@ A minimal widget above the editor shows live counts
72
71
 
73
72
  `babysit_run`, `babysit_wait`, and automatic completion notifications always
74
73
  return lifecycle metadata and the absolute path to the complete `output.log`.
75
- When the complete output is at most 8 KB it is returned inline; larger output
76
- stays out of model context. Inspect large logs on demand with bounded shell
77
- commands such as:
78
-
79
- ```sh
80
- tail -n 50 /path/to/output.log
81
- rg -n 'FAIL|ERROR' /path/to/output.log
74
+ Explicit run/wait results inline complete output up to 8 KB; unsolicited
75
+ completion notifications use a stricter 2 KB cap. Larger output stays out of
76
+ model context. Inspect it through the session id without creating another shell
77
+ session:
78
+
79
+ ```text
80
+ babysit_check { id: "cargo-test", lines: 50 }
81
+ babysit_check { id: "cargo-test", pattern: "FAIL|ERROR", lines: 50 }
82
82
  ```
83
83
 
84
- `babysit_check { id, lines }` remains available as a convenient bounded tail.
85
- Do not read a potentially large log file in full.
84
+ Tail and search results are capped at 200 lines and clipped to 8 KB. Pattern
85
+ search returns the latest matching lines with line numbers. Do not read a
86
+ potentially large log file in full.
86
87
 
87
- To enforce this policy, the extension blocks direct `bash` except for `pwd`,
88
- short Git status/branch checks, and `head`/`tail`/`rg` reads of `.log` files that
89
- are explicitly bounded to at most 100 lines. Broad searches, diffs, API calls,
90
- multiple commands, redirects, and shell wrappers are redirected to
91
- `babysit_run`. Set `PI_BABYSIT_ALLOW_BASH=1` only as an emergency escape hatch
92
- to disable this gate.
88
+ All shell commands, including `pwd` and Git, are redirected to `babysit_run`.
89
+ Set `PI_BABYSIT_ALLOW_BASH=1` only as an explicit emergency escape hatch.
93
90
 
94
- ## External worker death
91
+ ## Unexpected worker loss
95
92
 
96
- If endpoint security or another external actor kills the babysit supervisor,
97
- pi-babysit normalizes the stale `running` state to `worker-dead`, returns
98
- immediately instead of hanging, and explains that the command may have started.
99
- For commands known to be safe and idempotent, set `retryOnWorkerDeath: true` to
100
- retry once with a new session id. It is opt-in because blindly rerunning an
101
- arbitrary command can duplicate side effects.
93
+ If the babysit supervisor disappears without recording an exit, pi-babysit
94
+ normalizes the stale `running` state to `worker-dead` and returns immediately
95
+ instead of hanging. Possible causes include host process cleanup, endpoint
96
+ security, or a supervisor crash. For commands known to be safe and idempotent,
97
+ set `retryOnWorkerDeath: true` to retry once with a new session id. It is opt-in
98
+ because blindly rerunning an arbitrary command can duplicate side effects.
102
99
 
103
100
  ## How completion detection works
104
101
 
@@ -127,12 +124,18 @@ rule, so a subagent waiting on a long build is never false-killed.
127
124
  | `PI_BABYSIT_CLI` | `babysit` | babysit binary |
128
125
  | `PI_BABYSIT_VIEW_CMD` | bundled `format-stream.mjs` | live-attach pretty printer for subagent JSONL (`""` disables) |
129
126
  | `PI_BABYSIT_REAP_AFTER` | `120s` | idle grace before a finished subagent self-exits (`off`/`none`/`0` disables) |
130
-
131
- Requires `babysit` and `pi` on `PATH`. The extension does **not** auto-install
132
- `babysit`: if the binary is missing, every tool and the `/babysit` command fail
133
- with install instructions (`cargo install --git https://github.com/yusukeshib/babysit`
134
- or a prebuilt release), and a warning is shown at session start. Point
135
- `$PI_BABYSIT_CLI` at a custom binary path if needed.
127
+ | `PI_BABYSIT_TAIL_MAX_BYTES` | `8000` | cap for explicit log tails/screens returned by `babysit_check` |
128
+ | `PI_BABYSIT_INLINE_OUTPUT_MAX_BYTES` | `8000` | cap for complete output in explicitly requested run/wait results |
129
+ | `PI_BABYSIT_NOTIFY_OUTPUT_MAX_BYTES` | `2000` | smaller cap for unsolicited process-completion notifications (`0` omits all output) |
130
+ | `PI_BABYSIT_NOTIFY_COMMAND_MAX_BYTES` | `240` | cap for the command preview in completion notifications |
131
+ | `PI_BABYSIT_ALLOW_BASH` | unset | set to `1` to bypass direct-Bash redirection (emergency escape hatch) |
132
+
133
+ Requires `babysit` 0.13.0 or newer and `pi` on `PATH`. The extension does **not**
134
+ auto-install `babysit`: if the binary is missing or too old, every tool and the
135
+ `/babysit` command fail with install instructions (`cargo install --git
136
+ https://github.com/yusukeshib/babysit` or a prebuilt release), and a warning is
137
+ shown at session start. Point `$PI_BABYSIT_CLI` at a custom binary path if
138
+ needed.
136
139
 
137
140
  (No tmux dependency — `/babysit` renders inline; take over a live process
138
141
  manually with the `babysit attach` command it shows.)
package/index.ts CHANGED
@@ -81,6 +81,7 @@ const SUBAGENT_GUIDANCE = [
81
81
  ].join(" ");
82
82
  const POLL_MS = 2500;
83
83
  const QUICK_COMMAND_GRACE = process.env.PI_BABYSIT_QUICK_GRACE ?? "1s";
84
+ const KILL_CONFIRM_TIMEOUT = "4s";
84
85
 
85
86
  interface BsSession {
86
87
  id: string;
@@ -154,27 +155,49 @@ function bs(
154
155
  // Every session shells out to `babysit`; without it the extension can do
155
156
  // nothing. We don't auto-install (that's the user's job) — we fail loudly with
156
157
  // install instructions the moment a tool or command is used.
157
- const INSTALL_HINT =
158
- `The \`babysit\` binary was not found (tried "${BABYSIT_BIN}").\n` +
159
- `Install it, then retry:\n` +
158
+ const INSTALL_STEPS =
159
+ `Install babysit 0.13.0 or newer, then retry:\n` +
160
160
  ` cargo install --git https://github.com/yusukeshib/babysit\n` +
161
161
  `or download a prebuilt binary from https://github.com/yusukeshib/babysit/releases and put it on your PATH.\n` +
162
162
  `(Override the binary path with $PI_BABYSIT_CLI.)`;
163
+ const INSTALL_HINT =
164
+ `The \`babysit\` binary was not found (tried "${BABYSIT_BIN}").\n` + INSTALL_STEPS;
165
+ const MIN_BABYSIT_VERSION = [0, 13, 0] as const;
166
+
167
+ export function isSupportedBabysitVersion(output: string): boolean {
168
+ const match = /\b(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/.exec(output);
169
+ if (!match) return false;
170
+ const actual = [Number(match[1]), Number(match[2]), Number(match[3])] as const;
171
+ for (let i = 0; i < MIN_BABYSIT_VERSION.length; i++) {
172
+ if (actual[i] !== MIN_BABYSIT_VERSION[i]) return actual[i] > MIN_BABYSIT_VERSION[i];
173
+ }
174
+ return match[4] === undefined;
175
+ }
163
176
 
164
177
  // Cached preflight — probe `babysit --version` exactly once per process.
165
- let babysitOk: boolean | undefined;
178
+ // undefined = not probed, null = supported, string = actionable error.
179
+ let babysitPreflightError: string | null | undefined;
166
180
  async function babysitAvailable(): Promise<boolean> {
167
- if (babysitOk === undefined) {
168
- const r = await bs(["--version"]);
169
- babysitOk = r.code === 0;
181
+ // Cache only success. A missing or outdated binary may be installed while pi
182
+ // stays open, so subsequent tool calls must be able to recover without a restart.
183
+ if (babysitPreflightError === null) return true;
184
+ const r = await bs(["--version"]);
185
+ if (r.code !== 0) {
186
+ babysitPreflightError = INSTALL_HINT;
187
+ } else if (!isSupportedBabysitVersion(r.stdout)) {
188
+ babysitPreflightError =
189
+ `pi-babysit requires babysit 0.13.0 or newer; found ${r.stdout.trim() || "an unknown version"}.\n` +
190
+ INSTALL_STEPS;
191
+ } else {
192
+ babysitPreflightError = null;
170
193
  }
171
- return babysitOk;
194
+ return babysitPreflightError === null;
172
195
  }
173
196
 
174
197
  // Throwing form for tool `execute` handlers: a thrown error marks the tool
175
- // result isError and reports the install hint to the model.
198
+ // result isError and reports the preflight error to the model.
176
199
  async function requireBabysit(): Promise<void> {
177
- if (!(await babysitAvailable())) throw new Error(INSTALL_HINT);
200
+ if (!(await babysitAvailable())) throw new Error(babysitPreflightError ?? INSTALL_HINT);
178
201
  }
179
202
 
180
203
  // Error-aware: `babysit list` failing is NOT the same as "no sessions" —
@@ -219,6 +242,34 @@ async function statusOf(id: string): Promise<BsSession | null> {
219
242
  }
220
243
  }
221
244
 
245
+ export function isConfirmedTerminalState(state: string): boolean {
246
+ return state === "killed" || state === "exited";
247
+ }
248
+
249
+ export function validateKillResponse(stdout: string): string | null {
250
+ try {
251
+ const response = JSON.parse(stdout);
252
+ if (response.killed !== true || response.confirmed === false) {
253
+ return `Kill was not confirmed by babysit: ${stdout.trim()}`;
254
+ }
255
+ return null;
256
+ } catch {
257
+ return `Invalid kill response from babysit: ${stdout.trim() || "(empty)"}`;
258
+ }
259
+ }
260
+
261
+ async function awaitConfirmedTermination(id: string): Promise<BsSession | null> {
262
+ const initial = await statusOf(id);
263
+ if (!initial || isConfirmedTerminalState(initial.state) || initial.state === "dead") {
264
+ return initial;
265
+ }
266
+ // New babysit versions return only after persistence, so this is normally
267
+ // skipped. It is a bounded compatibility guard for older binaries that
268
+ // acknowledged signal delivery before the process actually exited.
269
+ await bs(["wait", "-s", id, "--timeout", KILL_CONFIRM_TIMEOUT]);
270
+ return statusOf(id);
271
+ }
272
+
222
273
  // ---------------------------------------------------------------------------
223
274
  // per-session metadata
224
275
  // ---------------------------------------------------------------------------
@@ -232,6 +283,12 @@ interface Meta {
232
283
  name?: string;
233
284
  command?: string;
234
285
  notified?: boolean;
286
+ // A confirmed kill permanently owns completion delivery. An interrupted
287
+ // concurrent wait must not re-enable the automatic notification afterward.
288
+ killNotificationSuppressed?: boolean;
289
+ // Temporary reservation while kill is in flight. Unlike `notified`, this
290
+ // must be cleared on failure so a real completion remains deliverable.
291
+ notificationPaused?: boolean;
235
292
  completionObservedAt?: number;
236
293
  startedAt?: number;
237
294
  // subagent
@@ -367,8 +424,19 @@ function parseDurMs(s?: string): number | null {
367
424
  // megabytes, so we also cap bytes, eliding the middle so both the head and
368
425
  // the tail of the output stay visible.
369
426
 
370
- const TAIL_MAX_BYTES = 8_000; // explicit tails / screens
371
- const INLINE_OUTPUT_MAX_BYTES = 8_000; // complete output returned only below this threshold
427
+ function byteLimitFromEnv(name: string, fallback: number): number {
428
+ const raw = process.env[name];
429
+ if (raw == null || raw.trim() === "") return fallback;
430
+ const value = Number(raw);
431
+ return Number.isSafeInteger(value) && value >= 0 ? value : fallback;
432
+ }
433
+
434
+ const TAIL_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_TAIL_MAX_BYTES", 8_000);
435
+ // Direct run/wait results can carry more context because the caller explicitly
436
+ // requested them. Unsolicited completion notifications default much smaller.
437
+ const INLINE_OUTPUT_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_INLINE_OUTPUT_MAX_BYTES", 8_000);
438
+ const NOTIFY_OUTPUT_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_OUTPUT_MAX_BYTES", 2_000);
439
+ const NOTIFY_COMMAND_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_COMMAND_MAX_BYTES", 240);
372
440
  const ANSWER_MAX_BYTES = 24_000; // subagent answers / error messages
373
441
 
374
442
  function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
@@ -381,7 +449,77 @@ function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
381
449
  return `${head}\n… [${buf.length - maxBytes} bytes elided] …\n${tail}`;
382
450
  }
383
451
 
384
- async function inlineOutput(id: string, status: BsSession): Promise<string> {
452
+ async function searchLog(
453
+ id: string,
454
+ pattern: string,
455
+ maxLines: number,
456
+ signal?: AbortSignal,
457
+ ): Promise<{ text: string; error?: string }> {
458
+ const file = logPath(id);
459
+ if (!fs.existsSync(file)) return { text: "", error: `Log file is missing: ${file}` };
460
+ if (signal?.aborted) return { text: "", error: "Log search was interrupted." };
461
+
462
+ // Run regex evaluation out of process so catastrophic backtracking or a huge
463
+ // no-newline log cannot freeze or exhaust pi's main Node process. The helper
464
+ // clips each retained line; this parent also enforces a hard wall-clock limit.
465
+ return new Promise((resolve) => {
466
+ const helper = path.join(EXT_DIR, "search-log.mjs");
467
+ const nodeOptions = [process.env.NODE_OPTIONS, "--max-old-space-size=32"]
468
+ .filter(Boolean)
469
+ .join(" ");
470
+ const child = spawn(process.execPath, [helper, file, pattern, String(maxLines)], {
471
+ env: { ...process.env, NODE_OPTIONS: nodeOptions },
472
+ });
473
+ let stdout = "";
474
+ let stderr = "";
475
+ let finished = false;
476
+ let timedOut = false;
477
+ const finish = (result: { text: string; error?: string }) => {
478
+ if (finished) return;
479
+ finished = true;
480
+ clearTimeout(timer);
481
+ signal?.removeEventListener("abort", onAbort);
482
+ resolve(result);
483
+ };
484
+ const onAbort = () => {
485
+ child.kill("SIGTERM");
486
+ finish({ text: "", error: "Log search was interrupted." });
487
+ };
488
+ const timer = setTimeout(() => {
489
+ timedOut = true;
490
+ child.kill("SIGTERM");
491
+ }, 3_000);
492
+ signal?.addEventListener("abort", onAbort, { once: true });
493
+ child.stdout?.on("data", (data) => {
494
+ stdout += data.toString();
495
+ });
496
+ child.stderr?.on("data", (data) => {
497
+ stderr = clip(stderr + data.toString());
498
+ });
499
+ child.on("error", (error) => {
500
+ finish({ text: "", error: `Could not start log search: ${String(error)}` });
501
+ });
502
+ child.on("close", (code) => {
503
+ if (timedOut) {
504
+ finish({ text: "", error: "Log search timed out after 3s; narrow the pattern or log." });
505
+ } else if (code !== 0) {
506
+ finish({ text: "", error: stderr.trim() || `Log search failed (exit ${code ?? "?"}).` });
507
+ } else {
508
+ finish({ text: clip(stdout.trimEnd()) });
509
+ }
510
+ });
511
+ });
512
+ }
513
+
514
+ export function shouldInlineCompleteOutput(outputBytes: number, maxBytes: number): boolean {
515
+ return maxBytes > 0 && outputBytes <= maxBytes;
516
+ }
517
+
518
+ async function inlineOutput(
519
+ id: string,
520
+ status: BsSession,
521
+ maxBytes = INLINE_OUTPUT_MAX_BYTES,
522
+ ): Promise<string> {
385
523
  let bytes = status.output_bytes;
386
524
  if (bytes == null) {
387
525
  try {
@@ -390,17 +528,38 @@ async function inlineOutput(id: string, status: BsSession): Promise<string> {
390
528
  bytes = Number.POSITIVE_INFINITY;
391
529
  }
392
530
  }
393
- if (bytes > INLINE_OUTPUT_MAX_BYTES) {
531
+ if (!shouldInlineCompleteOutput(bytes, maxBytes)) {
394
532
  const size = Number.isFinite(bytes) ? `${bytes} bytes` : "size unavailable";
395
- return `\nOutput omitted (${size}; inline limit ${INLINE_OUTPUT_MAX_BYTES}).`;
533
+ return `\nOutput omitted (${size}; inline limit ${maxBytes}).`;
396
534
  }
397
535
  const output = (await bs(["log", "-s", id])).stdout.trimEnd();
398
- if (Buffer.byteLength(output) > INLINE_OUTPUT_MAX_BYTES) {
399
- return `\nOutput omitted (exceeds inline limit ${INLINE_OUTPUT_MAX_BYTES} bytes).`;
536
+ if (Buffer.byteLength(output) > maxBytes) {
537
+ return `\nOutput omitted (exceeds inline limit ${maxBytes} bytes).`;
400
538
  }
401
539
  return output ? `\n\nOutput:\n${output}` : "";
402
540
  }
403
541
 
542
+ export function summarizeNotificationCommand(command: string | undefined): string {
543
+ const preview =
544
+ (command ?? "?")
545
+ .trim()
546
+ .replace(/\r/g, "\\r")
547
+ .replace(/\n/g, "\\n")
548
+ .replace(/\t/g, "\\t") || "?";
549
+ const bytes = Buffer.from(preview, "utf8");
550
+ if (bytes.length <= NOTIFY_COMMAND_MAX_BYTES) return preview;
551
+ if (NOTIFY_COMMAND_MAX_BYTES === 0) return "";
552
+ const ellipsis = Buffer.from("…", "utf8");
553
+ if (NOTIFY_COMMAND_MAX_BYTES <= ellipsis.length) {
554
+ return ".".repeat(NOTIFY_COMMAND_MAX_BYTES);
555
+ }
556
+ const prefix = bytes
557
+ .subarray(0, NOTIFY_COMMAND_MAX_BYTES - ellipsis.length)
558
+ .toString("utf8")
559
+ .replace(/\uFFFD+$/, "");
560
+ return `${prefix}…`;
561
+ }
562
+
404
563
 
405
564
  // ---------------------------------------------------------------------------
406
565
  // parked-turn detection (shared rule with self-reap.ts)
@@ -966,22 +1125,47 @@ async function waitForTask(
966
1125
 
967
1126
  // Mark a process session as already-reported so the exit-notification poller
968
1127
  // doesn't send a duplicate message for something the agent just observed.
969
- function suppressNotify(id: string): void {
1128
+ function suppressNotify(id: string, reason: "observed" | "kill" = "observed"): void {
970
1129
  const meta = readMeta(id);
971
- if (meta && meta.kind === "process" && !meta.notified) {
1130
+ if (meta && meta.kind === "process") {
972
1131
  meta.notified = true;
1132
+ if (reason === "kill") meta.killNotificationSuppressed = true;
1133
+ delete meta.notificationPaused;
973
1134
  writeMeta(id, meta);
974
1135
  }
975
1136
  }
976
1137
 
1138
+ export function canRestoreNotificationAfterWait(meta: {
1139
+ notified?: boolean;
1140
+ killNotificationSuppressed?: boolean;
1141
+ }): boolean {
1142
+ return meta.notified === true && meta.killNotificationSuppressed !== true;
1143
+ }
1144
+
977
1145
  function enableNotify(id: string): void {
978
1146
  const meta = readMeta(id);
979
- if (meta && meta.kind === "process" && meta.notified) {
1147
+ if (meta && meta.kind === "process" && canRestoreNotificationAfterWait(meta)) {
980
1148
  meta.notified = false;
981
1149
  writeMeta(id, meta);
982
1150
  }
983
1151
  }
984
1152
 
1153
+ function pauseNotify(id: string): void {
1154
+ const meta = readMeta(id);
1155
+ if (meta && meta.kind === "process" && !meta.notificationPaused) {
1156
+ meta.notificationPaused = true;
1157
+ writeMeta(id, meta);
1158
+ }
1159
+ }
1160
+
1161
+ function resumeNotify(id: string): void {
1162
+ const meta = readMeta(id);
1163
+ if (meta && meta.kind === "process" && meta.notificationPaused) {
1164
+ delete meta.notificationPaused;
1165
+ writeMeta(id, meta);
1166
+ }
1167
+ }
1168
+
985
1169
  // Wait for a PROCESS session: either until a regex appears in its output
986
1170
  // (`expect` — e.g. "server listening") or until the process exits.
987
1171
  async function waitForExit(
@@ -1055,13 +1239,13 @@ async function waitForExit(
1055
1239
  kind: "exited",
1056
1240
  ok,
1057
1241
  text:
1058
- `Process ${id}${meta?.command ? ` (${meta.command})` : ""} ` +
1242
+ `Process ${id}${meta?.command ? ` (${summarizeNotificationCommand(meta.command)})` : ""} ` +
1059
1243
  (workerDead
1060
1244
  ? "worker-dead: the babysit supervisor disappeared without an exit status"
1061
1245
  : ok ? "completed successfully" : `exited with code ${st.exit_code ?? "?"}`) +
1062
1246
  `${expectPattern ? ` before /${expectPattern}/ appeared` : ""}.` +
1063
1247
  (workerDead
1064
- ? " This commonly indicates an external kill (for example endpoint security). The command may have started, so retry only if it is safe and idempotent."
1248
+ ? " The supervisor disappeared without recording an exit; possible causes include host process cleanup, endpoint security, or a supervisor crash. The command may have started, so retry only if it is safe and idempotent."
1065
1249
  : "") +
1066
1250
  `\nLog: ${logPath(id)}` + output,
1067
1251
  status: st,
@@ -1079,7 +1263,7 @@ const waitFor = (
1079
1263
  : waitForExit(id, limitMs, signal, expectPattern);
1080
1264
 
1081
1265
  // ---------------------------------------------------------------------------
1082
- // bash background-command blocker (carried over from pi-processes)
1266
+ // direct bash policy
1083
1267
  // ---------------------------------------------------------------------------
1084
1268
 
1085
1269
  // Heuristic (no shell AST): catch `... &` backgrounding (not `&&`), nohup,
@@ -1092,28 +1276,9 @@ function backgroundsItself(command: string): boolean {
1092
1276
  return false;
1093
1277
  }
1094
1278
 
1095
- function boundedLineCount(command: string): number | null {
1096
- const matches = [...command.matchAll(/(?:^|\|)\s*(?:head|tail)\s+(?:-n\s+|--lines(?:=|\s+)|-)(\d+)\b/g)];
1097
- if (matches.length === 0) return null;
1098
- return Math.max(...matches.map((m) => Number(m[1])));
1099
- }
1100
-
1101
- /** Commands allowed to bypass babysit: tiny scalar observations and strictly
1102
- * bounded reads of captured log files. This is deliberately conservative;
1103
- * uncertain shell syntax belongs in babysit_run where output is capped. */
1104
- export function isAllowedDirectBash(command: string): boolean {
1105
- if (process.env.PI_BABYSIT_ALLOW_BASH === "1") return true;
1106
- const s = command.trim();
1107
- if (/^(pwd|git status --short|git branch --show-current)$/.test(s)) return true;
1108
- if (!s || /[;&`\n\r]|\$\(|>|\b(?:bash|sh|zsh)\s+-c\b/.test(s)) return false;
1109
- if (!/(?:output\.log|\/(?:tmp|var\/tmp)\/[^\s'\"]*\.log)\b/.test(s)) return false;
1110
- if (/^wc\s+-(?:l|c)\s+/.test(s) && !s.includes("|")) return true;
1111
- if (!/^(?:tail|head|rg)\b/.test(s)) return false;
1112
- const limit = boundedLineCount(s);
1113
- if (limit == null || limit > 100) return false;
1114
- // rg must feed a bounded head/tail; direct head/tail is already bounded.
1115
- if (/^rg\b/.test(s) && !/\|\s*(?:head|tail)\b/.test(s)) return false;
1116
- return true;
1279
+ /** Emergency escape hatch only. All ordinary shell commands go through babysit_run. */
1280
+ export function isAllowedDirectBash(_command: string): boolean {
1281
+ return process.env.PI_BABYSIT_ALLOW_BASH === "1";
1117
1282
  }
1118
1283
 
1119
1284
  // ---------------------------------------------------------------------------
@@ -1133,7 +1298,7 @@ export default function (pi: ExtensionAPI) {
1133
1298
  for (const s of sessions) {
1134
1299
  if (s.state === "running") continue;
1135
1300
  const meta = readMeta(s.id);
1136
- if (!meta || meta.kind !== "process" || meta.notified) continue;
1301
+ if (!meta || meta.kind !== "process" || meta.notified || meta.notificationPaused) continue;
1137
1302
  // Delay delivery by one poll interval. This gives an agent that chose
1138
1303
  // babysit_wait immediately after babysit_run enough time to claim the
1139
1304
  // completion and suppress the otherwise duplicate automatic message.
@@ -1143,15 +1308,13 @@ export default function (pi: ExtensionAPI) {
1143
1308
  continue;
1144
1309
  }
1145
1310
  if (Date.now() - meta.completionObservedAt < POLL_MS) continue;
1146
- meta.notified = true;
1147
- writeMeta(s.id, meta);
1148
1311
  const ok = s.exit_code === 0;
1149
1312
  const status: DisplayStatus = ok
1150
1313
  ? "success"
1151
1314
  : s.state === "dead" || s.exit_code == null
1152
1315
  ? "terminated"
1153
1316
  : "failed";
1154
- const output = await inlineOutput(s.id, s);
1317
+ const output = await inlineOutput(s.id, s, NOTIFY_OUTPUT_MAX_BYTES);
1155
1318
  const runtime = meta.startedAt
1156
1319
  ? `${Math.round((Date.now() - meta.startedAt) / 1000)}s`
1157
1320
  : "?";
@@ -1160,24 +1323,43 @@ export default function (pi: ExtensionAPI) {
1160
1323
  : s.state === "dead" || s.exit_code == null
1161
1324
  ? `Process "${s.id}" was terminated after ${runtime}.`
1162
1325
  : `Process "${s.id}" exited with code ${s.exit_code} after ${runtime}.`;
1163
- pi.sendMessage(
1164
- {
1165
- customType: "pi-babysit-process-end",
1166
- content:
1167
- `${summary}\nCommand: ${meta.command ?? "?"}\nLog: ${logPath(s.id)}${output}` +
1168
- `\n\nThis is the automatic process-end notification. Do not call babysit_check just to re-verify. Inspect the log only when needed, using bounded commands such as tail or rg; never read it in full.`,
1169
- display: true,
1170
- details: {
1171
- id: s.id,
1172
- exitCode: s.exit_code,
1173
- success: ok,
1174
- status,
1175
- runtime,
1176
- logPath: logPath(s.id),
1326
+ // Output loading is asynchronous. A kill/wait can claim completion in
1327
+ // that window, so re-read metadata immediately before delivery.
1328
+ const current = readMeta(s.id);
1329
+ if (
1330
+ !current ||
1331
+ current.kind !== "process" ||
1332
+ current.notified ||
1333
+ current.notificationPaused
1334
+ ) {
1335
+ continue;
1336
+ }
1337
+ try {
1338
+ pi.sendMessage(
1339
+ {
1340
+ customType: "pi-babysit-process-end",
1341
+ content:
1342
+ `${summary}\nCommand: ${summarizeNotificationCommand(current.command)}\nLog: ${logPath(s.id)}${output}` +
1343
+ "\n\nAutomatic completion notification. Inspect the bounded log with babysit_check only if needed.",
1344
+ display: true,
1345
+ details: {
1346
+ id: s.id,
1347
+ exitCode: s.exit_code,
1348
+ success: ok,
1349
+ status,
1350
+ runtime,
1351
+ logPath: logPath(s.id),
1352
+ },
1177
1353
  },
1178
- },
1179
- { triggerTurn: true, deliverAs: "steer" },
1180
- );
1354
+ { triggerTurn: true, deliverAs: "steer" },
1355
+ );
1356
+ } catch {
1357
+ // Leave it pending so the next poll can retry delivery.
1358
+ continue;
1359
+ }
1360
+ current.notified = true;
1361
+ delete current.notificationPaused;
1362
+ writeMeta(s.id, current);
1181
1363
  }
1182
1364
  }
1183
1365
 
@@ -1282,7 +1464,9 @@ export default function (pi: ExtensionAPI) {
1282
1464
  }
1283
1465
  // Warn early if the binary is missing so the user isn't surprised only when
1284
1466
  // a tool later fails. Tools/commands still enforce it via requireBabysit.
1285
- if (ctx.hasUI && !(await babysitAvailable())) ctx.ui.notify(INSTALL_HINT, "warn");
1467
+ if (ctx.hasUI && !(await babysitAvailable())) {
1468
+ ctx.ui.notify(babysitPreflightError ?? INSTALL_HINT, "warn");
1469
+ }
1286
1470
  if (pollTimer) clearInterval(pollTimer);
1287
1471
  pollTimer = setInterval(() => {
1288
1472
  // Skip if the previous (async) poll hasn't finished, so slow babysit
@@ -1304,9 +1488,6 @@ export default function (pi: ExtensionAPI) {
1304
1488
  pollTimer = undefined;
1305
1489
  });
1306
1490
 
1307
- // Keep unpredictable command output out of model context. Direct bash is
1308
- // reserved for tiny scalar observations and tightly bounded log inspection;
1309
- // everything else belongs in babysit_run.
1310
1491
  pi.on("tool_call", async (event) => {
1311
1492
  if (event.toolName !== "bash") return;
1312
1493
  const command = String((event.input as { command?: unknown }).command ?? "");
@@ -1322,8 +1503,8 @@ export default function (pi: ExtensionAPI) {
1322
1503
  return {
1323
1504
  block: true,
1324
1505
  reason:
1325
- "Use babysit_run for this command so potentially large output is captured outside model context. " +
1326
- "Direct bash is allowed only for pwd, short git status/branch checks, or log-file tail/rg commands explicitly bounded to at most 100 lines. " +
1506
+ "Use babysit_run for shell commands so output is supervised and captured outside model context. " +
1507
+ "Inspect an existing session log with babysit_check { id, lines, pattern? }. " +
1327
1508
  `Retry as babysit_run({ command: ${JSON.stringify(command)} }).`,
1328
1509
  };
1329
1510
  });
@@ -1336,11 +1517,11 @@ export default function (pi: ExtensionAPI) {
1336
1517
  "Run any shell command in a supervised babysit session. Commands that finish within a short " +
1337
1518
  "grace period return completion metadata immediately; longer commands continue in the background " +
1338
1519
  "and trigger an automatic notification on exit. Complete output is returned inline only when it is " +
1339
- "small; larger output stays in the log path for bounded inspection with tail or rg. " +
1520
+ "small; larger output stays in the log path for bounded inspection with babysit_check. " +
1340
1521
  "In non-interactive mode (`pi -p`, no UI), process mode blocks until exit because there is no " +
1341
1522
  "notification loop. Two modes: (1) `command` — run any shell command, including builds, tests, " +
1342
1523
  "dev servers, watchers, and interactive TUIs; you can type into it with babysit_send and read " +
1343
- "its screen with babysit_check. If endpoint security kills a worker at startup, " +
1524
+ "its screen with babysit_check. If a worker disappears during startup without recording an exit, " +
1344
1525
  "`retryOnWorkerDeath` can retry one idempotent command once. " +
1345
1526
  "(2) `profile: \"subagent\"` + `task` — spawn a pi subagent that works on the task in the " +
1346
1527
  "background; poll with babysit_check, steer with babysit_send, block with babysit_wait, " +
@@ -1349,7 +1530,7 @@ export default function (pi: ExtensionAPI) {
1349
1530
  "Run any shell command with context-safe captured output; quick commands return metadata, longer ones continue in background",
1350
1531
  promptGuidelines: [
1351
1532
  "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`.",
1352
- "Inspect a babysit log only with explicitly bounded commands such as `tail -n N` or focused `rg`; never read or cat a potentially large log in full. Those small inspection commands may use bash directly to avoid recursively creating babysit sessions.",
1533
+ "Inspect a babysit log with babysit_check { id, lines, pattern? }; never read or cat a potentially large log file in full.",
1353
1534
  "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').",
1354
1535
  "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.",
1355
1536
  "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 }.",
@@ -1667,10 +1848,10 @@ export default function (pi: ExtensionAPI) {
1667
1848
  label: "Babysit: check",
1668
1849
  description:
1669
1850
  "Inspect babysit session(s). Without an id: lists all sessions (processes + subagents). " +
1670
- "With an id: a process shows state + recent output (or the rendered screen with " +
1671
- "`screen: true` — use for TUIs that redraw in place); a subagent shows live progress " +
1672
- "(turns, recent tool calls, partial answer). Do NOT poll this while merely waiting for " +
1673
- "a process to end — the exit notification is automatic.",
1851
+ "With an id: a process shows state + recent output, searches its log with `pattern`, " +
1852
+ "or captures the rendered screen with `screen: true`; a subagent shows live progress " +
1853
+ "(or raw log matches with `pattern`). Results are bounded by `lines` and clipped. " +
1854
+ "Do NOT poll this while merely waiting for a process to end — the exit notification is automatic.",
1674
1855
  promptSnippet: "Check status/progress of babysit sessions (processes and subagents)",
1675
1856
  parameters: Type.Object({
1676
1857
  id: Type.Optional(Type.String({ description: "Session id. Omit to list all sessions." })),
@@ -1678,7 +1859,13 @@ export default function (pi: ExtensionAPI) {
1678
1859
  Type.Number({ description: "Subagent: how many recent tool calls to show (default 8, max 50)." }),
1679
1860
  ),
1680
1861
  lines: Type.Optional(
1681
- Type.Number({ description: "Process: how many log lines to show (default 30, max 200)." }),
1862
+ Type.Number({ description: "How many tail lines or latest matches to show (default 30, max 200)." }),
1863
+ ),
1864
+ pattern: Type.Optional(
1865
+ Type.String({
1866
+ description:
1867
+ "Search this session's raw log with a regular expression; returns the latest bounded matches.",
1868
+ }),
1682
1869
  ),
1683
1870
  screen: Type.Optional(
1684
1871
  Type.Boolean({
@@ -1687,7 +1874,7 @@ export default function (pi: ExtensionAPI) {
1687
1874
  }),
1688
1875
  ),
1689
1876
  }),
1690
- async execute(_id, params) {
1877
+ async execute(_id, params, signal) {
1691
1878
  await requireBabysit();
1692
1879
  if (!params.id) {
1693
1880
  const { sessions, error } = await listSessions();
@@ -1722,6 +1909,40 @@ export default function (pi: ExtensionAPI) {
1722
1909
  };
1723
1910
  }
1724
1911
  const meta = readMeta(params.id);
1912
+ const nLines = Math.min(Math.max(1, Math.floor(params.lines ?? 30)), 200);
1913
+ if (params.pattern !== undefined) {
1914
+ if (params.screen) {
1915
+ return {
1916
+ content: [{ type: "text", text: "`pattern` and `screen` are mutually exclusive." }],
1917
+ isError: true,
1918
+ details: {},
1919
+ };
1920
+ }
1921
+ if (params.pattern.length === 0) {
1922
+ return {
1923
+ content: [{ type: "text", text: "`pattern` must not be empty." }],
1924
+ isError: true,
1925
+ details: {},
1926
+ };
1927
+ }
1928
+ const result = await searchLog(params.id, params.pattern, nLines, signal);
1929
+ if (result.error) {
1930
+ return {
1931
+ content: [{ type: "text", text: result.error }],
1932
+ isError: true,
1933
+ details: {},
1934
+ };
1935
+ }
1936
+ const kind = meta?.kind ?? "process";
1937
+ const header = `[${kind}] state=${st.state}\nlog: ${logPath(params.id)}`;
1938
+ const body = result.text
1939
+ ? `--- latest matches /${params.pattern}/ ---\n${result.text}`
1940
+ : `(no output matching /${params.pattern}/)`;
1941
+ return {
1942
+ content: [{ type: "text", text: `${header}\n${body}` }],
1943
+ details: { status: st, kind, logPath: logPath(params.id), pattern: params.pattern },
1944
+ };
1945
+ }
1725
1946
 
1726
1947
  // --- process ---
1727
1948
  if (meta?.kind !== "subagent") {
@@ -1740,7 +1961,6 @@ export default function (pi: ExtensionAPI) {
1740
1961
  const sc = await bs(["screenshot", "-s", params.id, "--trim"]);
1741
1962
  parts.push(`--- screen ---\n${clip(sc.stdout.trimEnd()) || "(blank screen)"}`);
1742
1963
  } else {
1743
- const nLines = Math.min(Math.max(1, params.lines ?? 30), 200);
1744
1964
  const tail = clip(
1745
1965
  (await bs(["log", "-s", params.id, "--tail", String(nLines)])).stdout.trimEnd(),
1746
1966
  );
@@ -2078,17 +2298,46 @@ export default function (pi: ExtensionAPI) {
2078
2298
  parameters: Type.Object({ id: Type.String({ description: "Session id." }) }),
2079
2299
  async execute(_id, params, _signal, _onUpdate, ctx) {
2080
2300
  await requireBabysit();
2081
- suppressNotify(params.id); // tool-initiated kill → no end notification
2082
- const r = await bs(["kill", "-s", params.id, "--json"]);
2083
- await refreshWidget(ctx);
2084
- if (r.code !== 0) {
2301
+ // Prevent the exit poller racing a requested kill, but restore delivery
2302
+ // on every failure. Permanent suppression happens only after terminal
2303
+ // state is independently confirmed.
2304
+ pauseNotify(params.id);
2305
+ const fail = async (message: string, status?: BsSession | null) => {
2306
+ resumeNotify(params.id);
2307
+ await refreshWidget(ctx);
2085
2308
  return {
2086
- content: [{ type: "text", text: r.stderr || "kill failed" }],
2309
+ content: [{ type: "text" as const, text: message }],
2087
2310
  isError: true,
2088
- details: {},
2311
+ details: { id: params.id, status: status?.state, logPath: logPath(params.id) },
2089
2312
  };
2313
+ };
2314
+
2315
+ const r = await bs(["kill", "-s", params.id, "--json"]);
2316
+ if (r.code !== 0) return fail((r.stderr || r.stdout || "kill failed").trim());
2317
+
2318
+ const responseError = validateKillResponse(r.stdout);
2319
+ if (responseError) return fail(responseError);
2320
+
2321
+ const status = await awaitConfirmedTermination(params.id);
2322
+ if (!status) return fail(`Kill could not be verified: session ${params.id} disappeared.`);
2323
+ if (!isConfirmedTerminalState(status.state)) {
2324
+ return fail(
2325
+ `Kill was acknowledged but ${params.id} is still ${status.state}; completion notifications were restored.`,
2326
+ status,
2327
+ );
2090
2328
  }
2091
- return { content: [{ type: "text", text: `Killed ${params.id}.` }], details: {} };
2329
+
2330
+ suppressNotify(params.id, "kill");
2331
+ await refreshWidget(ctx);
2332
+ return {
2333
+ content: [{ type: "text", text: `Killed ${params.id} (confirmed ${status.state}).` }],
2334
+ details: {
2335
+ id: params.id,
2336
+ status: status.state,
2337
+ exitCode: status.exit_code,
2338
+ logPath: logPath(params.id),
2339
+ },
2340
+ };
2092
2341
  },
2093
2342
  });
2094
2343
 
@@ -2101,7 +2350,7 @@ export default function (pi: ExtensionAPI) {
2101
2350
  description: "Pick a babysit session (↑/↓) to snapshot/inspect",
2102
2351
  handler: async (_args, ctx) => {
2103
2352
  if (!(await babysitAvailable())) {
2104
- ctx.ui.notify(INSTALL_HINT, "error");
2353
+ ctx.ui.notify(babysitPreflightError ?? INSTALL_HINT, "error");
2105
2354
  return;
2106
2355
  }
2107
2356
  const sessions = (await listSessions()).sessions.sort((a, b) =>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.3.4",
3
+ "version": "0.3.7",
4
4
  "description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -23,6 +23,7 @@
23
23
  "agents.ts",
24
24
  "self-reap.ts",
25
25
  "format-stream.mjs",
26
+ "search-log.mjs",
26
27
  "README.md",
27
28
  "LICENSE"
28
29
  ],
package/search-log.mjs ADDED
@@ -0,0 +1,42 @@
1
+ import fs from "node:fs";
2
+ import { createInterface } from "node:readline";
3
+
4
+ const [file, source, maxLinesRaw] = process.argv.slice(2);
5
+ const maxLines = Math.min(Math.max(1, Number.parseInt(maxLinesRaw ?? "30", 10) || 30), 200);
6
+ const MAX_LINE_BYTES = 4_000;
7
+
8
+ let pattern;
9
+ try {
10
+ pattern = new RegExp(source);
11
+ } catch (error) {
12
+ console.error(`Invalid pattern: ${String(error)}`);
13
+ process.exit(2);
14
+ }
15
+
16
+ function clipLine(line) {
17
+ const bytes = Buffer.from(line, "utf8");
18
+ if (bytes.length <= MAX_LINE_BYTES) return line;
19
+ const half = Math.floor(MAX_LINE_BYTES / 2);
20
+ const head = bytes.subarray(0, half).toString("utf8").replace(/\uFFFD+$/, "");
21
+ const tail = bytes.subarray(bytes.length - half).toString("utf8").replace(/^\uFFFD+/, "");
22
+ return `${head}… [line clipped] …${tail}`;
23
+ }
24
+
25
+ const matches = [];
26
+ let lineNumber = 0;
27
+ try {
28
+ const input = fs.createReadStream(file, { encoding: "utf8" });
29
+ const lines = createInterface({ input, crlfDelay: Infinity });
30
+ for await (const line of lines) {
31
+ lineNumber++;
32
+ pattern.lastIndex = 0;
33
+ if (!pattern.test(line)) continue;
34
+ matches.push(`${lineNumber}:${clipLine(line)}`);
35
+ if (matches.length > maxLines) matches.shift();
36
+ }
37
+ } catch (error) {
38
+ console.error(`Could not search log: ${String(error)}`);
39
+ process.exit(1);
40
+ }
41
+
42
+ process.stdout.write(matches.join("\n"));