@yusukeshib/pi-babysit 0.3.3 → 0.3.6

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
56
  | `babysit_kill` | Terminate a session (suppresses 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
 
@@ -73,32 +72,29 @@ A minimal widget above the editor shows live counts
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
74
  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:
75
+ stays out of model context. Inspect it through the session id without creating
76
+ another shell session:
78
77
 
79
- ```sh
80
- tail -n 50 /path/to/output.log
81
- rg -n 'FAIL|ERROR' /path/to/output.log
78
+ ```text
79
+ babysit_check { id: "cargo-test", lines: 50 }
80
+ babysit_check { id: "cargo-test", pattern: "FAIL|ERROR", lines: 50 }
82
81
  ```
83
82
 
84
- `babysit_check { id, lines }` remains available as a convenient bounded tail.
85
- Do not read a potentially large log file in full.
83
+ Tail and search results are capped at 200 lines and clipped to 8 KB. Pattern
84
+ search returns the latest matching lines with line numbers. Do not read a
85
+ potentially large log file in full.
86
86
 
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.
87
+ All shell commands, including `pwd` and Git, are redirected to `babysit_run`.
88
+ Set `PI_BABYSIT_ALLOW_BASH=1` only as an explicit emergency escape hatch.
93
89
 
94
- ## External worker death
90
+ ## Unexpected worker loss
95
91
 
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.
92
+ If the babysit supervisor disappears without recording an exit, pi-babysit
93
+ normalizes the stale `running` state to `worker-dead` and returns immediately
94
+ instead of hanging. Possible causes include host process cleanup, endpoint
95
+ security, or a supervisor crash. For commands known to be safe and idempotent,
96
+ set `retryOnWorkerDeath: true` to retry once with a new session id. It is opt-in
97
+ because blindly rerunning an arbitrary command can duplicate side effects.
102
98
 
103
99
  ## How completion detection works
104
100
 
@@ -127,12 +123,14 @@ rule, so a subagent waiting on a long build is never false-killed.
127
123
  | `PI_BABYSIT_CLI` | `babysit` | babysit binary |
128
124
  | `PI_BABYSIT_VIEW_CMD` | bundled `format-stream.mjs` | live-attach pretty printer for subagent JSONL (`""` disables) |
129
125
  | `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.
126
+ | `PI_BABYSIT_ALLOW_BASH` | unset | set to `1` to bypass direct-Bash redirection (emergency escape hatch) |
127
+
128
+ Requires `babysit` 0.13.0 or newer and `pi` on `PATH`. The extension does **not**
129
+ auto-install `babysit`: if the binary is missing or too old, every tool and the
130
+ `/babysit` command fail with install instructions (`cargo install --git
131
+ https://github.com/yusukeshib/babysit` or a prebuilt release), and a warning is
132
+ shown at session start. Point `$PI_BABYSIT_CLI` at a custom binary path if
133
+ needed.
136
134
 
137
135
  (No tmux dependency — `/babysit` renders inline; take over a live process
138
136
  manually with the `babysit attach` command it shows.)
package/index.ts CHANGED
@@ -36,9 +36,9 @@ 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 } from "@earendil-works/pi-coding-agent";
39
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
40
40
  import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
41
- import { Box, Markdown, Text } from "@earendil-works/pi-tui";
41
+ import { Box, Container, Markdown, Text } from "@earendil-works/pi-tui";
42
42
  import { Type } from "typebox";
43
43
  import { StringEnum } from "@earendil-works/pi-ai";
44
44
  import { type AgentConfig, type AgentScope, discoverAgents } from "./agents";
@@ -154,27 +154,49 @@ function bs(
154
154
  // Every session shells out to `babysit`; without it the extension can do
155
155
  // nothing. We don't auto-install (that's the user's job) — we fail loudly with
156
156
  // 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` +
157
+ const INSTALL_STEPS =
158
+ `Install babysit 0.13.0 or newer, then retry:\n` +
160
159
  ` cargo install --git https://github.com/yusukeshib/babysit\n` +
161
160
  `or download a prebuilt binary from https://github.com/yusukeshib/babysit/releases and put it on your PATH.\n` +
162
161
  `(Override the binary path with $PI_BABYSIT_CLI.)`;
162
+ const INSTALL_HINT =
163
+ `The \`babysit\` binary was not found (tried "${BABYSIT_BIN}").\n` + INSTALL_STEPS;
164
+ const MIN_BABYSIT_VERSION = [0, 13, 0] as const;
165
+
166
+ export function isSupportedBabysitVersion(output: string): boolean {
167
+ const match = /\b(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/.exec(output);
168
+ if (!match) return false;
169
+ const actual = [Number(match[1]), Number(match[2]), Number(match[3])] as const;
170
+ for (let i = 0; i < MIN_BABYSIT_VERSION.length; i++) {
171
+ if (actual[i] !== MIN_BABYSIT_VERSION[i]) return actual[i] > MIN_BABYSIT_VERSION[i];
172
+ }
173
+ return match[4] === undefined;
174
+ }
163
175
 
164
176
  // Cached preflight — probe `babysit --version` exactly once per process.
165
- let babysitOk: boolean | undefined;
177
+ // undefined = not probed, null = supported, string = actionable error.
178
+ let babysitPreflightError: string | null | undefined;
166
179
  async function babysitAvailable(): Promise<boolean> {
167
- if (babysitOk === undefined) {
168
- const r = await bs(["--version"]);
169
- babysitOk = r.code === 0;
180
+ // Cache only success. A missing or outdated binary may be installed while pi
181
+ // stays open, so subsequent tool calls must be able to recover without a restart.
182
+ if (babysitPreflightError === null) return true;
183
+ const r = await bs(["--version"]);
184
+ if (r.code !== 0) {
185
+ babysitPreflightError = INSTALL_HINT;
186
+ } else if (!isSupportedBabysitVersion(r.stdout)) {
187
+ babysitPreflightError =
188
+ `pi-babysit requires babysit 0.13.0 or newer; found ${r.stdout.trim() || "an unknown version"}.\n` +
189
+ INSTALL_STEPS;
190
+ } else {
191
+ babysitPreflightError = null;
170
192
  }
171
- return babysitOk;
193
+ return babysitPreflightError === null;
172
194
  }
173
195
 
174
196
  // Throwing form for tool `execute` handlers: a thrown error marks the tool
175
- // result isError and reports the install hint to the model.
197
+ // result isError and reports the preflight error to the model.
176
198
  async function requireBabysit(): Promise<void> {
177
- if (!(await babysitAvailable())) throw new Error(INSTALL_HINT);
199
+ if (!(await babysitAvailable())) throw new Error(babysitPreflightError ?? INSTALL_HINT);
178
200
  }
179
201
 
180
202
  // Error-aware: `babysit list` failing is NOT the same as "no sessions" —
@@ -381,6 +403,68 @@ function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
381
403
  return `${head}\n… [${buf.length - maxBytes} bytes elided] …\n${tail}`;
382
404
  }
383
405
 
406
+ async function searchLog(
407
+ id: string,
408
+ pattern: string,
409
+ maxLines: number,
410
+ signal?: AbortSignal,
411
+ ): Promise<{ text: string; error?: string }> {
412
+ const file = logPath(id);
413
+ if (!fs.existsSync(file)) return { text: "", error: `Log file is missing: ${file}` };
414
+ if (signal?.aborted) return { text: "", error: "Log search was interrupted." };
415
+
416
+ // Run regex evaluation out of process so catastrophic backtracking or a huge
417
+ // no-newline log cannot freeze or exhaust pi's main Node process. The helper
418
+ // clips each retained line; this parent also enforces a hard wall-clock limit.
419
+ return new Promise((resolve) => {
420
+ const helper = path.join(EXT_DIR, "search-log.mjs");
421
+ const nodeOptions = [process.env.NODE_OPTIONS, "--max-old-space-size=32"]
422
+ .filter(Boolean)
423
+ .join(" ");
424
+ const child = spawn(process.execPath, [helper, file, pattern, String(maxLines)], {
425
+ env: { ...process.env, NODE_OPTIONS: nodeOptions },
426
+ });
427
+ let stdout = "";
428
+ let stderr = "";
429
+ let finished = false;
430
+ let timedOut = false;
431
+ const finish = (result: { text: string; error?: string }) => {
432
+ if (finished) return;
433
+ finished = true;
434
+ clearTimeout(timer);
435
+ signal?.removeEventListener("abort", onAbort);
436
+ resolve(result);
437
+ };
438
+ const onAbort = () => {
439
+ child.kill("SIGTERM");
440
+ finish({ text: "", error: "Log search was interrupted." });
441
+ };
442
+ const timer = setTimeout(() => {
443
+ timedOut = true;
444
+ child.kill("SIGTERM");
445
+ }, 3_000);
446
+ signal?.addEventListener("abort", onAbort, { once: true });
447
+ child.stdout?.on("data", (data) => {
448
+ stdout += data.toString();
449
+ });
450
+ child.stderr?.on("data", (data) => {
451
+ stderr = clip(stderr + data.toString());
452
+ });
453
+ child.on("error", (error) => {
454
+ finish({ text: "", error: `Could not start log search: ${String(error)}` });
455
+ });
456
+ child.on("close", (code) => {
457
+ if (timedOut) {
458
+ finish({ text: "", error: "Log search timed out after 3s; narrow the pattern or log." });
459
+ } else if (code !== 0) {
460
+ finish({ text: "", error: stderr.trim() || `Log search failed (exit ${code ?? "?"}).` });
461
+ } else {
462
+ finish({ text: clip(stdout.trimEnd()) });
463
+ }
464
+ });
465
+ });
466
+ }
467
+
384
468
  async function inlineOutput(id: string, status: BsSession): Promise<string> {
385
469
  let bytes = status.output_bytes;
386
470
  if (bytes == null) {
@@ -1061,7 +1145,7 @@ async function waitForExit(
1061
1145
  : ok ? "completed successfully" : `exited with code ${st.exit_code ?? "?"}`) +
1062
1146
  `${expectPattern ? ` before /${expectPattern}/ appeared` : ""}.` +
1063
1147
  (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."
1148
+ ? " 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
1149
  : "") +
1066
1150
  `\nLog: ${logPath(id)}` + output,
1067
1151
  status: st,
@@ -1079,7 +1163,7 @@ const waitFor = (
1079
1163
  : waitForExit(id, limitMs, signal, expectPattern);
1080
1164
 
1081
1165
  // ---------------------------------------------------------------------------
1082
- // bash background-command blocker (carried over from pi-processes)
1166
+ // direct bash policy
1083
1167
  // ---------------------------------------------------------------------------
1084
1168
 
1085
1169
  // Heuristic (no shell AST): catch `... &` backgrounding (not `&&`), nohup,
@@ -1092,28 +1176,9 @@ function backgroundsItself(command: string): boolean {
1092
1176
  return false;
1093
1177
  }
1094
1178
 
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;
1179
+ /** Emergency escape hatch only. All ordinary shell commands go through babysit_run. */
1180
+ export function isAllowedDirectBash(_command: string): boolean {
1181
+ return process.env.PI_BABYSIT_ALLOW_BASH === "1";
1117
1182
  }
1118
1183
 
1119
1184
  // ---------------------------------------------------------------------------
@@ -1146,6 +1211,11 @@ export default function (pi: ExtensionAPI) {
1146
1211
  meta.notified = true;
1147
1212
  writeMeta(s.id, meta);
1148
1213
  const ok = s.exit_code === 0;
1214
+ const status: DisplayStatus = ok
1215
+ ? "success"
1216
+ : s.state === "dead" || s.exit_code == null
1217
+ ? "terminated"
1218
+ : "failed";
1149
1219
  const output = await inlineOutput(s.id, s);
1150
1220
  const runtime = meta.startedAt
1151
1221
  ? `${Math.round((Date.now() - meta.startedAt) / 1000)}s`
@@ -1160,9 +1230,16 @@ export default function (pi: ExtensionAPI) {
1160
1230
  customType: "pi-babysit-process-end",
1161
1231
  content:
1162
1232
  `${summary}\nCommand: ${meta.command ?? "?"}\nLog: ${logPath(s.id)}${output}` +
1163
- `\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.`,
1233
+ `\n\nThis is the automatic process-end notification. Do not call babysit_check just to re-verify. Inspect the log only when needed with babysit_check { id: ${JSON.stringify(s.id)}, lines, pattern? }; never read it in full.`,
1164
1234
  display: true,
1165
- details: { id: s.id, exitCode: s.exit_code, success: ok, runtime, logPath: logPath(s.id) },
1235
+ details: {
1236
+ id: s.id,
1237
+ exitCode: s.exit_code,
1238
+ success: ok,
1239
+ status,
1240
+ runtime,
1241
+ logPath: logPath(s.id),
1242
+ },
1166
1243
  },
1167
1244
  { triggerTurn: true, deliverAs: "steer" },
1168
1245
  );
@@ -1198,23 +1275,63 @@ export default function (pi: ExtensionAPI) {
1198
1275
  ctx.ui.setWidget("pi-babysit", lines, { placement: "belowEditor" });
1199
1276
  };
1200
1277
 
1201
- // Render a subagent's final answer INLINE in the transcript as formatted
1202
- // markdown (agent output is markdown). Fed via pi.sendMessage below.
1278
+ type DisplayStatus = "started" | "running" | "idle" | "success" | "failed" | "terminated";
1279
+ const renderStatus = (status: DisplayStatus, theme: Theme, prefix?: string): string => {
1280
+ const labels: Record<
1281
+ DisplayStatus,
1282
+ { icon: string; text: string; color: "accent" | "warning" | "success" | "error" }
1283
+ > = {
1284
+ started: { icon: "", text: "STARTED", color: "accent" },
1285
+ running: { icon: "", text: "RUNNING", color: "accent" },
1286
+ idle: { icon: "", text: "IDLE", color: "warning" },
1287
+ success: { icon: "", text: "SUCCESS", color: "success" },
1288
+ failed: { icon: "", text: "FAILED", color: "error" },
1289
+ terminated: { icon: "", text: "TERMINATED", color: "error" },
1290
+ };
1291
+ const label = labels[status];
1292
+ const text = prefix ? `${prefix} ${label.text}` : label.text;
1293
+ const decorated = label.icon ? `${label.icon} ${text}` : text;
1294
+ return theme.fg(label.color, theme.bold(decorated));
1295
+ };
1296
+ const outcomeStatus = (outcome: WaitOutcome): DisplayStatus =>
1297
+ outcome.ok
1298
+ ? "success"
1299
+ : outcome.status &&
1300
+ (outcome.status.state === "dead" || outcome.status.exit_code == null)
1301
+ ? "terminated"
1302
+ : "failed";
1303
+
1304
+ // Render snapshots and subagent answers INLINE in the transcript as formatted
1305
+ // markdown, with a semantic status label that remains readable on any theme.
1203
1306
  pi.registerMessageRenderer("pi-babysit-result", (message, _opts, theme) => {
1204
- const d = (message.details ?? {}) as { title?: string; body?: string };
1307
+ const d = (message.details ?? {}) as {
1308
+ title?: string;
1309
+ body?: string;
1310
+ status?: DisplayStatus;
1311
+ };
1205
1312
  const body =
1206
1313
  d.body ?? (typeof message.content === "string" ? message.content : "");
1207
- const box = new Box(1, 0, (t) => theme.bg("customMessageBg", t));
1314
+ const box = new Box(1, 0, (t) => theme.bg("toolSuccessBg", t));
1315
+ if (d.status) box.addChild(new Text(renderStatus(d.status, theme), 0, 0));
1208
1316
  if (d.title) box.addChild(new Text(theme.fg("accent", d.title), 0, 0));
1209
1317
  box.addChild(new Markdown(body, 0, 0, getMarkdownTheme()));
1210
1318
  return box;
1211
1319
  });
1212
1320
 
1213
- // Process-end notification rendering (plain text in a subtle box).
1321
+ // Process-end notification rendering with a colored lifecycle label. Keep the
1322
+ // box background subtle: coloring a potentially large log excerpt is noisy.
1214
1323
  pi.registerMessageRenderer("pi-babysit-process-end", (message, _opts, theme) => {
1215
1324
  const content = typeof message.content === "string" ? message.content : "";
1216
- const box = new Box(1, 0, (t) => theme.bg("customMessageBg", t));
1217
- box.addChild(new Text(content, 0, 0));
1325
+ const d = (message.details ?? {}) as {
1326
+ status?: DisplayStatus;
1327
+ success?: boolean;
1328
+ exitCode?: number | null;
1329
+ };
1330
+ const status =
1331
+ d.status ?? (d.success ? "success" : d.exitCode == null ? "terminated" : "failed");
1332
+ const box = new Box(1, 1, (t) => theme.bg("toolSuccessBg", t));
1333
+ box.addChild(new Text(renderStatus(status, theme, "babysit_run"), 0, 0));
1334
+ box.addChild(new Text(theme.fg("toolOutput", content), 0, 0));
1218
1335
  return box;
1219
1336
  });
1220
1337
 
@@ -1230,7 +1347,9 @@ export default function (pi: ExtensionAPI) {
1230
1347
  }
1231
1348
  // Warn early if the binary is missing so the user isn't surprised only when
1232
1349
  // a tool later fails. Tools/commands still enforce it via requireBabysit.
1233
- if (ctx.hasUI && !(await babysitAvailable())) ctx.ui.notify(INSTALL_HINT, "warn");
1350
+ if (ctx.hasUI && !(await babysitAvailable())) {
1351
+ ctx.ui.notify(babysitPreflightError ?? INSTALL_HINT, "warn");
1352
+ }
1234
1353
  if (pollTimer) clearInterval(pollTimer);
1235
1354
  pollTimer = setInterval(() => {
1236
1355
  // Skip if the previous (async) poll hasn't finished, so slow babysit
@@ -1252,9 +1371,6 @@ export default function (pi: ExtensionAPI) {
1252
1371
  pollTimer = undefined;
1253
1372
  });
1254
1373
 
1255
- // Keep unpredictable command output out of model context. Direct bash is
1256
- // reserved for tiny scalar observations and tightly bounded log inspection;
1257
- // everything else belongs in babysit_run.
1258
1374
  pi.on("tool_call", async (event) => {
1259
1375
  if (event.toolName !== "bash") return;
1260
1376
  const command = String((event.input as { command?: unknown }).command ?? "");
@@ -1270,8 +1386,8 @@ export default function (pi: ExtensionAPI) {
1270
1386
  return {
1271
1387
  block: true,
1272
1388
  reason:
1273
- "Use babysit_run for this command so potentially large output is captured outside model context. " +
1274
- "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. " +
1389
+ "Use babysit_run for shell commands so output is supervised and captured outside model context. " +
1390
+ "Inspect an existing session log with babysit_check { id, lines, pattern? }. " +
1275
1391
  `Retry as babysit_run({ command: ${JSON.stringify(command)} }).`,
1276
1392
  };
1277
1393
  });
@@ -1284,11 +1400,11 @@ export default function (pi: ExtensionAPI) {
1284
1400
  "Run any shell command in a supervised babysit session. Commands that finish within a short " +
1285
1401
  "grace period return completion metadata immediately; longer commands continue in the background " +
1286
1402
  "and trigger an automatic notification on exit. Complete output is returned inline only when it is " +
1287
- "small; larger output stays in the log path for bounded inspection with tail or rg. " +
1403
+ "small; larger output stays in the log path for bounded inspection with babysit_check. " +
1288
1404
  "In non-interactive mode (`pi -p`, no UI), process mode blocks until exit because there is no " +
1289
1405
  "notification loop. Two modes: (1) `command` — run any shell command, including builds, tests, " +
1290
1406
  "dev servers, watchers, and interactive TUIs; you can type into it with babysit_send and read " +
1291
- "its screen with babysit_check. If endpoint security kills a worker at startup, " +
1407
+ "its screen with babysit_check. If a worker disappears during startup without recording an exit, " +
1292
1408
  "`retryOnWorkerDeath` can retry one idempotent command once. " +
1293
1409
  "(2) `profile: \"subagent\"` + `task` — spawn a pi subagent that works on the task in the " +
1294
1410
  "background; poll with babysit_check, steer with babysit_send, block with babysit_wait, " +
@@ -1297,7 +1413,7 @@ export default function (pi: ExtensionAPI) {
1297
1413
  "Run any shell command with context-safe captured output; quick commands return metadata, longer ones continue in background",
1298
1414
  promptGuidelines: [
1299
1415
  "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`.",
1300
- "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.",
1416
+ "Inspect a babysit log with babysit_check { id, lines, pattern? }; never read or cat a potentially large log file in full.",
1301
1417
  "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').",
1302
1418
  "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.",
1303
1419
  "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 }.",
@@ -1433,7 +1549,14 @@ export default function (pi: ExtensionAPI) {
1433
1549
  return {
1434
1550
  content: [{ type: "text", text: `${retried ? "Retried once after external worker death.\n" : ""}${outcome.text}` }],
1435
1551
  isError: !outcome.ok,
1436
- details: { id: res.id, kind: "process", command: params.command, logPath: logPath(res.id), retried },
1552
+ details: {
1553
+ id: res.id,
1554
+ kind: "process",
1555
+ command: params.command,
1556
+ logPath: logPath(res.id),
1557
+ retried,
1558
+ status: outcomeStatus(outcome),
1559
+ },
1437
1560
  };
1438
1561
  }
1439
1562
 
@@ -1462,7 +1585,14 @@ export default function (pi: ExtensionAPI) {
1462
1585
  return {
1463
1586
  content: [{ type: "text", text: `${retried ? "Retried once after external worker death.\n" : ""}${outcome.text}` }],
1464
1587
  isError: !outcome.ok,
1465
- details: { id: res.id, kind: "process", command: params.command, logPath: logPath(res.id), retried },
1588
+ details: {
1589
+ id: res.id,
1590
+ kind: "process",
1591
+ command: params.command,
1592
+ logPath: logPath(res.id),
1593
+ retried,
1594
+ status: outcomeStatus(outcome),
1595
+ },
1466
1596
  };
1467
1597
  }
1468
1598
 
@@ -1482,7 +1612,14 @@ export default function (pi: ExtensionAPI) {
1482
1612
  `Human can watch/take over: /babysit`,
1483
1613
  },
1484
1614
  ],
1485
- details: { id: res.id, kind: "process", command: params.command, logPath: logPath(res.id), retried },
1615
+ details: {
1616
+ id: res.id,
1617
+ kind: "process",
1618
+ command: params.command,
1619
+ logPath: logPath(res.id),
1620
+ retried,
1621
+ status: "started" satisfies DisplayStatus,
1622
+ },
1486
1623
  // Do not return `terminate: true` here. In RPC/subagent hosts that hint
1487
1624
  // can shut down the hosting pi worker, whose process-tree cleanup then
1488
1625
  // kills the otherwise detached babysit supervisor and closes its PTY
@@ -1542,8 +1679,49 @@ export default function (pi: ExtensionAPI) {
1542
1679
  `Human can watch/steer: /babysit (pick ${res.id})`,
1543
1680
  },
1544
1681
  ],
1545
- details: { id: res.id, kind: "subagent", agent: agent?.name, model: res.model, task: params.task },
1682
+ details: {
1683
+ id: res.id,
1684
+ kind: "subagent",
1685
+ agent: agent?.name,
1686
+ model: res.model,
1687
+ task: params.task,
1688
+ status: "started" satisfies DisplayStatus,
1689
+ },
1690
+ };
1691
+ },
1692
+ // The result label already includes "babysit_run"; suppress the default
1693
+ // call header so the tool name is not shown twice in adjacent lines.
1694
+ renderCall() {
1695
+ return new Container();
1696
+ },
1697
+ renderResult(result, { isPartial }, theme, context) {
1698
+ const details = (result.details ?? {}) as {
1699
+ kind?: "process" | "subagent";
1700
+ status?: DisplayStatus;
1546
1701
  };
1702
+ const content = result.content
1703
+ .filter((item): item is { type: "text"; text: string } => item.type === "text")
1704
+ .map((item) => item.text)
1705
+ .join("\n");
1706
+ // A vanished supervisor must never be presented as success, even if an
1707
+ // older/stale result omitted status details or the host did not preserve
1708
+ // the custom isError field. The textual diagnosis is part of our stable
1709
+ // tool contract, so give it precedence over all fallback classification.
1710
+ const workerDead =
1711
+ content.includes("worker-dead") ||
1712
+ content.includes("babysit supervisor disappeared");
1713
+ const status: DisplayStatus = isPartial
1714
+ ? "running"
1715
+ : workerDead
1716
+ ? "terminated"
1717
+ : details.status ??
1718
+ (context.isError
1719
+ ? "failed"
1720
+ : details.kind === "subagent" || content.includes(NOTIFY_MARKER)
1721
+ ? "started"
1722
+ : "success");
1723
+ const label = renderStatus(status, theme, "babysit_run");
1724
+ return new Text(content ? `${label}\n${theme.fg("toolOutput", content)}` : label, 0, 0);
1547
1725
  },
1548
1726
  });
1549
1727
 
@@ -1553,10 +1731,10 @@ export default function (pi: ExtensionAPI) {
1553
1731
  label: "Babysit: check",
1554
1732
  description:
1555
1733
  "Inspect babysit session(s). Without an id: lists all sessions (processes + subagents). " +
1556
- "With an id: a process shows state + recent output (or the rendered screen with " +
1557
- "`screen: true` — use for TUIs that redraw in place); a subagent shows live progress " +
1558
- "(turns, recent tool calls, partial answer). Do NOT poll this while merely waiting for " +
1559
- "a process to end — the exit notification is automatic.",
1734
+ "With an id: a process shows state + recent output, searches its log with `pattern`, " +
1735
+ "or captures the rendered screen with `screen: true`; a subagent shows live progress " +
1736
+ "(or raw log matches with `pattern`). Results are bounded by `lines` and clipped. " +
1737
+ "Do NOT poll this while merely waiting for a process to end — the exit notification is automatic.",
1560
1738
  promptSnippet: "Check status/progress of babysit sessions (processes and subagents)",
1561
1739
  parameters: Type.Object({
1562
1740
  id: Type.Optional(Type.String({ description: "Session id. Omit to list all sessions." })),
@@ -1564,7 +1742,13 @@ export default function (pi: ExtensionAPI) {
1564
1742
  Type.Number({ description: "Subagent: how many recent tool calls to show (default 8, max 50)." }),
1565
1743
  ),
1566
1744
  lines: Type.Optional(
1567
- Type.Number({ description: "Process: how many log lines to show (default 30, max 200)." }),
1745
+ Type.Number({ description: "How many tail lines or latest matches to show (default 30, max 200)." }),
1746
+ ),
1747
+ pattern: Type.Optional(
1748
+ Type.String({
1749
+ description:
1750
+ "Search this session's raw log with a regular expression; returns the latest bounded matches.",
1751
+ }),
1568
1752
  ),
1569
1753
  screen: Type.Optional(
1570
1754
  Type.Boolean({
@@ -1573,7 +1757,7 @@ export default function (pi: ExtensionAPI) {
1573
1757
  }),
1574
1758
  ),
1575
1759
  }),
1576
- async execute(_id, params) {
1760
+ async execute(_id, params, signal) {
1577
1761
  await requireBabysit();
1578
1762
  if (!params.id) {
1579
1763
  const { sessions, error } = await listSessions();
@@ -1608,6 +1792,40 @@ export default function (pi: ExtensionAPI) {
1608
1792
  };
1609
1793
  }
1610
1794
  const meta = readMeta(params.id);
1795
+ const nLines = Math.min(Math.max(1, Math.floor(params.lines ?? 30)), 200);
1796
+ if (params.pattern !== undefined) {
1797
+ if (params.screen) {
1798
+ return {
1799
+ content: [{ type: "text", text: "`pattern` and `screen` are mutually exclusive." }],
1800
+ isError: true,
1801
+ details: {},
1802
+ };
1803
+ }
1804
+ if (params.pattern.length === 0) {
1805
+ return {
1806
+ content: [{ type: "text", text: "`pattern` must not be empty." }],
1807
+ isError: true,
1808
+ details: {},
1809
+ };
1810
+ }
1811
+ const result = await searchLog(params.id, params.pattern, nLines, signal);
1812
+ if (result.error) {
1813
+ return {
1814
+ content: [{ type: "text", text: result.error }],
1815
+ isError: true,
1816
+ details: {},
1817
+ };
1818
+ }
1819
+ const kind = meta?.kind ?? "process";
1820
+ const header = `[${kind}] state=${st.state}\nlog: ${logPath(params.id)}`;
1821
+ const body = result.text
1822
+ ? `--- latest matches /${params.pattern}/ ---\n${result.text}`
1823
+ : `(no output matching /${params.pattern}/)`;
1824
+ return {
1825
+ content: [{ type: "text", text: `${header}\n${body}` }],
1826
+ details: { status: st, kind, logPath: logPath(params.id), pattern: params.pattern },
1827
+ };
1828
+ }
1611
1829
 
1612
1830
  // --- process ---
1613
1831
  if (meta?.kind !== "subagent") {
@@ -1626,7 +1844,6 @@ export default function (pi: ExtensionAPI) {
1626
1844
  const sc = await bs(["screenshot", "-s", params.id, "--trim"]);
1627
1845
  parts.push(`--- screen ---\n${clip(sc.stdout.trimEnd()) || "(blank screen)"}`);
1628
1846
  } else {
1629
- const nLines = Math.min(Math.max(1, params.lines ?? 30), 200);
1630
1847
  const tail = clip(
1631
1848
  (await bs(["log", "-s", params.id, "--tail", String(nLines)])).stdout.trimEnd(),
1632
1849
  );
@@ -1987,7 +2204,7 @@ export default function (pi: ExtensionAPI) {
1987
2204
  description: "Pick a babysit session (↑/↓) to snapshot/inspect",
1988
2205
  handler: async (_args, ctx) => {
1989
2206
  if (!(await babysitAvailable())) {
1990
- ctx.ui.notify(INSTALL_HINT, "error");
2207
+ ctx.ui.notify(babysitPreflightError ?? INSTALL_HINT, "error");
1991
2208
  return;
1992
2209
  }
1993
2210
  const sessions = (await listSessions()).sessions.sort((a, b) =>
@@ -2044,12 +2261,21 @@ export default function (pi: ExtensionAPI) {
2044
2261
  `${picked.id} ${picked.state}${elapsedSuffix}` +
2045
2262
  (picked.exit_code != null ? ` (exit=${picked.exit_code})` : "") +
2046
2263
  ` ${stats}`;
2264
+ const status: DisplayStatus = prog.errorMsg
2265
+ ? "failed"
2266
+ : prog.running || prog.waitingOnProcess
2267
+ ? "running"
2268
+ : prog.done
2269
+ ? "idle"
2270
+ : picked.exit_code === 0
2271
+ ? "success"
2272
+ : "terminated";
2047
2273
  if (ctx.hasUI) {
2048
2274
  pi.sendMessage({
2049
2275
  customType: "pi-babysit-result",
2050
2276
  content: title,
2051
2277
  display: true,
2052
- details: { title, body },
2278
+ details: { title, body, status },
2053
2279
  });
2054
2280
  } else {
2055
2281
  ctx.ui.notify(`${title}\n\n${body}`, "info");
@@ -2078,12 +2304,19 @@ export default function (pi: ExtensionAPI) {
2078
2304
  (running
2079
2305
  ? `\n\n_Take over in your own terminal:_ \`${attachCmd(picked.id)}\` _(detach: Ctrl-\\ Ctrl-\\)._ Re-run \`/babysit\` to refresh this snapshot.`
2080
2306
  : "");
2307
+ const status: DisplayStatus = running
2308
+ ? "running"
2309
+ : picked.exit_code === 0
2310
+ ? "success"
2311
+ : picked.state === "dead" || picked.exit_code == null
2312
+ ? "terminated"
2313
+ : "failed";
2081
2314
  if (ctx.hasUI) {
2082
2315
  pi.sendMessage({
2083
2316
  customType: "pi-babysit-result",
2084
2317
  content: title,
2085
2318
  display: true,
2086
- details: { title, body },
2319
+ details: { title, body, status },
2087
2320
  });
2088
2321
  } else {
2089
2322
  ctx.ui.notify(`${title}\n\n${body}`, "info");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.3.3",
3
+ "version": "0.3.6",
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"));