@yusukeshib/pi-babysit 0.2.4 → 0.3.1

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 +27 -36
  2. package/index.ts +127 -252
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # pi-babysit
2
2
 
3
- A [pi](https://github.com/earendil-works/pi) extension that runs **anything
4
- long-lived** under [babysit](https://github.com/yusukeshib/babysit)-supervised
5
- PTY sessions — one substrate for background processes **and** pi subagents.
3
+ A [pi](https://github.com/earendil-works/pi) extension that runs **any shell
4
+ command** under [babysit](https://github.com/yusukeshib/babysit)-supervised
5
+ sessions — one context-safe substrate for quick commands, background processes,
6
+ and pi subagents.
6
7
  It retires both `@mjakl/pi-processes` (the `process` tool) and the old
7
8
  `pi-subagent` extension.
8
9
 
@@ -48,9 +49,8 @@ programs** (installers, wizards, REPLs): type with `babysit_send`
48
49
 
49
50
  | Tool | What it does |
50
51
  | ---- | ------------ |
51
- | `babysit_run` | Start a process (`command`, optional `name`/`pty`/`timeout`/`idleTimeout`) or a subagent (`profile: "subagent"`, `task`, optional `agent`/`model`/`tools`). Non-blocking; returns a session id |
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 |
52
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_analyze` | Run a local JavaScript, Python, or shell analyzer over a process's complete captured log; only its bounded report returns to the model |
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) |
@@ -68,39 +68,30 @@ A `tool_call` hook also blocks bash commands that background themselves
68
68
  A minimal widget above the editor shows live counts
69
69
  (`N processes · M subagents working · K idle`).
70
70
 
71
- ## Log reports without context flooding
72
-
73
- A process run can include a `report` program. On completion, babysit writes the
74
- complete recorded log to a temporary file, runs the program in a separate local
75
- child process, and sends **only the program's bounded stdout** in the automatic
76
- completion notification. This avoids putting a full test/build log in the model
77
- context while preserving the original log for `babysit_check`, `/babysit`, or
78
- `babysit attach`.
79
-
80
- ```ts
81
- babysit_run({
82
- name: "test",
83
- command: "npm test",
84
- report: {
85
- language: "javascript",
86
- code: `
87
- const failures = FILE_CONTENT.split("\\n")
88
- .filter(line => /FAIL|Error:|✗/.test(line));
89
- console.log(`failures: ${failures.length}`);
90
- console.log(failures.slice(-20).join("\\n"));
91
- `,
92
- },
93
- });
71
+ ## Logs without context flooding
72
+
73
+ `babysit_run`, `babysit_wait`, and automatic completion notifications always
74
+ 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
94
82
  ```
95
83
 
96
- JavaScript and Python reports receive `FILE_CONTENT` and `INPUT`; shell reports
97
- read the log path from `$BABYSIT_REPORT_INPUT`. `report.timeout` defaults to
98
- 30 seconds. Input is capped at 64 MiB and returned report output at 12 KB.
99
- Use `babysit_analyze` with the same `language`, `code`, and optional `timeout`
100
- fields to analyze a running process's output so far or re-analyze a completed
101
- process. Report code executes with your local user permissions, just like any
102
- other extension tool command; it is isolated from the pi extension host but is
103
- not an OS security sandbox.
84
+ `babysit_check { id, lines }` remains available as a convenient bounded tail.
85
+ Do not read a potentially large log file in full.
86
+
87
+ ## External worker death
88
+
89
+ If endpoint security or another external actor kills the babysit supervisor,
90
+ pi-babysit normalizes the stale `running` state to `worker-dead`, returns
91
+ immediately instead of hanging, and explains that the command may have started.
92
+ For commands known to be safe and idempotent, set `retryOnWorkerDeath: true` to
93
+ retry once with a new session id. It is opt-in because blindly rerunning an
94
+ arbitrary command can duplicate side effects.
104
95
 
105
96
  ## How completion detection works
106
97
 
package/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * pi-babysit: run ANYTHING long-lived under babysit — one supervision substrate
3
- * for background processes AND pi subagents. Retires both `pi-processes`
2
+ * pi-babysit: run ANY shell command under babysit — one context-safe supervision
3
+ * substrate for quick commands, background processes, AND pi subagents. Retires both `pi-processes`
4
4
  * (the `process` tool) and `pi-subagent`.
5
5
  *
6
6
  * Every session is a babysit-supervised PTY (state in $PI_BABYSIT_DIR,
@@ -26,7 +26,7 @@
26
26
  * surface (babysit_run/check/send/wait/kill) covers both, and domain knowledge
27
27
  * (RPC bookkeeping, byte offsets, parked-turn detection) stays in code.
28
28
  *
29
- * Tools (LLM): babysit_run, babysit_check, babysit_analyze, babysit_send, babysit_wait, babysit_kill
29
+ * Tools (LLM): babysit_run, babysit_check, babysit_send, babysit_wait, babysit_kill
30
30
  * Commands: /babysit (arrow-key picker: attach/tail/inspect)
31
31
  * Widget: live counts (processes running · subagents working · idle)
32
32
  */
@@ -80,6 +80,7 @@ const SUBAGENT_GUIDANCE = [
80
80
  "your controller reads it from the event stream.",
81
81
  ].join(" ");
82
82
  const POLL_MS = 2500;
83
+ const QUICK_COMMAND_GRACE = process.env.PI_BABYSIT_QUICK_GRACE ?? "1s";
83
84
 
84
85
  interface BsSession {
85
86
  id: string;
@@ -225,21 +226,13 @@ async function statusOf(id: string): Promise<BsSession | null> {
225
226
  // kind=process: name/command + `notified` (exit notification dedup).
226
227
  // kind=subagent: task + the raw-log byte offset of the last prompt, which lets
227
228
  // check/wait analyze only the CURRENT task's events (important for follow-ups).
228
- type ReportLanguage = "javascript" | "python" | "shell";
229
-
230
- interface ReportSpec {
231
- language: ReportLanguage;
232
- code: string;
233
- timeout?: string;
234
- }
235
-
236
229
  interface Meta {
237
230
  kind: "process" | "subagent";
238
231
  // process
239
232
  name?: string;
240
233
  command?: string;
241
- report?: ReportSpec;
242
234
  notified?: boolean;
235
+ completionObservedAt?: number;
243
236
  startedAt?: number;
244
237
  // subagent
245
238
  task?: string;
@@ -248,6 +241,7 @@ interface Meta {
248
241
  }
249
242
 
250
243
  const metaDir = () => path.join(ROOT, "meta");
244
+ const logPath = (id: string) => path.join(ROOT, "sessions", id, "output.log");
251
245
 
252
246
  function writeMeta(id: string, m: Meta): void {
253
247
  try {
@@ -373,11 +367,9 @@ function parseDurMs(s?: string): number | null {
373
367
  // megabytes, so we also cap bytes, eliding the middle so both the head and
374
368
  // the tail of the output stay visible.
375
369
 
376
- const TAIL_MAX_BYTES = 8_000; // log tails / screens
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
377
372
  const ANSWER_MAX_BYTES = 24_000; // subagent answers / error messages
378
- const REPORT_MAX_INPUT_BYTES = 64 * 1024 * 1024; // analyzer input log
379
- const REPORT_MAX_OUTPUT_BYTES = 12_000; // analyzer stdout/stderr returned to the agent
380
- const REPORT_DEFAULT_TIMEOUT_MS = 30_000;
381
373
 
382
374
  function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
383
375
  const buf = Buffer.from(s, "utf8");
@@ -389,132 +381,27 @@ function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
389
381
  return `${head}\n… [${buf.length - maxBytes} bytes elided] …\n${tail}`;
390
382
  }
391
383
 
392
- // ---------------------------------------------------------------------------
393
- // Report analyzers — a report receives the complete captured process log, but
394
- // only its bounded stdout is returned to the model. It intentionally runs in
395
- // a child process: report code is arbitrary user/model-authored code and must
396
- // never be evaluated inside pi's extension host.
397
-
398
- interface ReportOutcome {
399
- ok: boolean;
400
- text: string;
401
- }
402
-
403
- async function saveSessionLog(id: string, destination: string, signal?: AbortSignal): Promise<string | null> {
404
- return new Promise((resolve) => {
405
- const child = spawn(BABYSIT_BIN, ["log", "-s", id], {
406
- env: { ...process.env, BABYSIT_DIR: ROOT },
407
- });
408
- const output = fs.createWriteStream(destination);
409
- let bytes = 0;
410
- let finished = false;
411
- const finish = (error: string | null) => {
412
- if (finished) return;
413
- finished = true;
414
- signal?.removeEventListener("abort", abort);
415
- output.end(() => resolve(error));
416
- };
417
- const abort = () => {
418
- child.kill("SIGTERM");
419
- finish("report analysis was cancelled");
420
- };
421
- if (signal?.aborted) return abort();
422
- signal?.addEventListener("abort", abort, { once: true });
423
- child.stdout?.on("data", (chunk: Buffer) => {
424
- if (finished) return;
425
- bytes += chunk.length;
426
- if (bytes > REPORT_MAX_INPUT_BYTES) {
427
- child.kill("SIGTERM");
428
- finish(`process log exceeds the ${REPORT_MAX_INPUT_BYTES / 1024 / 1024} MiB report limit`);
429
- return;
430
- }
431
- output.write(chunk);
432
- });
433
- child.on("error", (error) => finish(`could not read process log: ${error.message}`));
434
- child.on("close", (code) => {
435
- if (finished) return;
436
- finish(code === 0 ? null : `could not read process log (babysit log exited ${code ?? 1})`);
437
- });
438
- });
439
- }
440
-
441
- async function executeReport(spec: ReportSpec, inputPath: string, signal?: AbortSignal): Promise<ReportOutcome> {
442
- const dir = path.dirname(inputPath);
443
- const scriptPath = path.join(dir, spec.language === "python" ? "report.py" : spec.language === "shell" ? "report.sh" : "report.cjs");
444
- const script =
445
- spec.language === "javascript"
446
- ? `const fs = require("node:fs");\nconst FILE_CONTENT = fs.readFileSync(process.env.BABYSIT_REPORT_INPUT, "utf8");\nconst INPUT = FILE_CONTENT;\n(async () => {\n${spec.code}\n})().catch((error) => { console.error(error?.stack ?? String(error)); process.exitCode = 1; });\n`
447
- : spec.language === "python"
448
- ? `import os\nfrom pathlib import Path\nFILE_CONTENT = Path(os.environ["BABYSIT_REPORT_INPUT"]).read_text()\nINPUT = FILE_CONTENT\n${spec.code}\n`
449
- : `# Read the complete log from $BABYSIT_REPORT_INPUT.\n${spec.code}\n`;
450
- fs.writeFileSync(scriptPath, script, { mode: 0o700 });
451
- const command = spec.language === "javascript" ? process.execPath : spec.language === "python" ? "python3" : SHELL;
452
- const args = spec.language === "shell" ? [scriptPath] : [scriptPath];
453
- const timeoutMs = parseDurMs(spec.timeout) ?? REPORT_DEFAULT_TIMEOUT_MS;
454
-
455
- return new Promise((resolve) => {
456
- const child = spawn(command, args, {
457
- env: { ...process.env, BABYSIT_REPORT_INPUT: inputPath },
458
- });
459
- let stdout = "";
460
- let stderr = "";
461
- let outputTruncated = false;
462
- let timedOut = false;
463
- let settled = false;
464
- const append = (current: string, chunk: Buffer) => {
465
- const remaining = REPORT_MAX_OUTPUT_BYTES - Buffer.byteLength(current);
466
- if (remaining <= 0) {
467
- outputTruncated = true;
468
- return current;
469
- }
470
- const text = chunk.subarray(0, remaining).toString("utf8");
471
- if (text.length < chunk.length) outputTruncated = true;
472
- return current + text;
473
- };
474
- const finish = (code: number | null) => {
475
- if (settled) return;
476
- settled = true;
477
- clearTimeout(timer);
478
- signal?.removeEventListener("abort", abort);
479
- const body = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
480
- const suffix = outputTruncated ? "\n[report output truncated]" : "";
481
- if (timedOut) {
482
- resolve({ ok: false, text: `Report timed out after ${Math.ceil(timeoutMs / 1000)}s.${body ? `\n${clip(body, REPORT_MAX_OUTPUT_BYTES)}` : ""}${suffix}` });
483
- } else if (code === 0) {
484
- resolve({ ok: true, text: `${body || "(report produced no output)"}${suffix}` });
485
- } else {
486
- resolve({ ok: false, text: `Report failed (exit ${code ?? 1}).${body ? `\n${clip(body, REPORT_MAX_OUTPUT_BYTES)}` : ""}${suffix}` });
487
- }
488
- };
489
- const abort = () => child.kill("SIGTERM");
490
- const timer = setTimeout(() => {
491
- timedOut = true;
492
- child.kill("SIGTERM");
493
- }, timeoutMs);
494
- if (signal?.aborted) abort();
495
- signal?.addEventListener("abort", abort, { once: true });
496
- child.stdout?.on("data", (chunk: Buffer) => { stdout = append(stdout, chunk); });
497
- child.stderr?.on("data", (chunk: Buffer) => { stderr = append(stderr, chunk); });
498
- child.on("error", (error) => {
499
- stderr = append(stderr, Buffer.from(error.message));
500
- finish(1);
501
- });
502
- child.on("close", finish);
503
- });
504
- }
505
-
506
- async function runReport(id: string, spec: ReportSpec, signal?: AbortSignal): Promise<ReportOutcome> {
507
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-babysit-report-"));
508
- try {
509
- const inputPath = path.join(dir, "process.log");
510
- const error = await saveSessionLog(id, inputPath, signal);
511
- if (error) return { ok: false, text: `Could not prepare report input: ${error}` };
512
- return await executeReport(spec, inputPath, signal);
513
- } finally {
514
- fs.rmSync(dir, { recursive: true, force: true });
384
+ async function inlineOutput(id: string, status: BsSession): Promise<string> {
385
+ let bytes = status.output_bytes;
386
+ if (bytes == null) {
387
+ try {
388
+ bytes = fs.statSync(logPath(id)).size;
389
+ } catch {
390
+ bytes = Number.POSITIVE_INFINITY;
391
+ }
392
+ }
393
+ if (bytes > INLINE_OUTPUT_MAX_BYTES) {
394
+ const size = Number.isFinite(bytes) ? `${bytes} bytes` : "size unavailable";
395
+ return `\nOutput omitted (${size}; inline limit ${INLINE_OUTPUT_MAX_BYTES}).`;
396
+ }
397
+ 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).`;
515
400
  }
401
+ return output ? `\n\nOutput:\n${output}` : "";
516
402
  }
517
403
 
404
+
518
405
  // ---------------------------------------------------------------------------
519
406
  // parked-turn detection (shared rule with self-reap.ts)
520
407
  // ---------------------------------------------------------------------------
@@ -691,7 +578,6 @@ interface ProcOpts {
691
578
  timeout?: string; // default: none — dev servers may run indefinitely
692
579
  idleTimeout?: string;
693
580
  pty: boolean;
694
- report?: ReportSpec;
695
581
  }
696
582
 
697
583
  async function spawnProcess(opts: ProcOpts): Promise<{ id: string } | { error: string }> {
@@ -721,7 +607,6 @@ async function spawnProcess(opts: ProcOpts): Promise<{ id: string } | { error: s
721
607
  kind: "process",
722
608
  name: opts.name ?? id,
723
609
  command: opts.command,
724
- report: opts.report,
725
610
  notified: false,
726
611
  startedAt: Date.now(),
727
612
  });
@@ -970,7 +855,7 @@ interface WaitOutcome {
970
855
  // Wait for ONE subagent's current task. Completion = an agent_end whose last
971
856
  // message is NOT a parked babysit_run/process toolResult (that one only means
972
857
  // "turn parked, waiting for a background process — pi resumes on its own").
973
- // Loop: analyze the current-task log slice; done → report; still going →
858
+ // Loop: analyze the current-task log slice; done → return; still going →
974
859
  // block on the next agent_end via `babysit expect` (race-free byte offsets).
975
860
  async function waitForTask(
976
861
  id: string,
@@ -1089,6 +974,14 @@ function suppressNotify(id: string): void {
1089
974
  }
1090
975
  }
1091
976
 
977
+ function enableNotify(id: string): void {
978
+ const meta = readMeta(id);
979
+ if (meta && meta.kind === "process" && meta.notified) {
980
+ meta.notified = false;
981
+ writeMeta(id, meta);
982
+ }
983
+ }
984
+
1092
985
  // Wait for a PROCESS session: either until a regex appears in its output
1093
986
  // (`expect` — e.g. "server listening") or until the process exits.
1094
987
  async function waitForExit(
@@ -1105,12 +998,11 @@ async function waitForExit(
1105
998
  return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
1106
999
  }
1107
1000
  if (e.code === 0) {
1108
- const tail = clip((await bs(["log", "-s", id, "--tail", "10"])).stdout.trim());
1109
1001
  return {
1110
1002
  id,
1111
1003
  kind: "done",
1112
1004
  ok: true,
1113
- text: `Pattern /${expectPattern}/ matched in ${id} output (process still running).\n\nRecent output:\n${tail}`,
1005
+ text: `Pattern /${expectPattern}/ matched in ${id} output (process still running).\nLog: ${logPath(id)}`,
1114
1006
  };
1115
1007
  }
1116
1008
  const st0 = await statusOf(id);
@@ -1125,14 +1017,19 @@ async function waitForExit(
1125
1017
  }
1126
1018
  // fall through: session exited before the pattern appeared
1127
1019
  } else {
1020
+ // An explicit wait owns completion delivery. Mark it before blocking so
1021
+ // the exit poller cannot race us and inject a duplicate notification.
1022
+ suppressNotify(id);
1128
1023
  const w = await bs(["wait", "-s", id, "--timeout", t], { signal });
1129
1024
  if (signal?.aborted || w.code === 130) {
1025
+ enableNotify(id);
1130
1026
  return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
1131
1027
  }
1132
1028
  if (w.code === 124) {
1133
1029
  // 124 is ambiguous (timeout vs child exiting 124) — disambiguate.
1134
1030
  const st0 = await statusOf(id);
1135
1031
  if (st0?.state === "running") {
1032
+ enableNotify(id);
1136
1033
  return {
1137
1034
  id,
1138
1035
  kind: "timeout",
@@ -1150,20 +1047,23 @@ async function waitForExit(
1150
1047
  }
1151
1048
  suppressNotify(id); // the agent sees the exit here; don't notify again
1152
1049
  const meta = readMeta(id);
1153
- const report = meta?.report ? await runReport(id, meta.report, signal) : null;
1154
- const tail = report ? "" : clip((await bs(["log", "-s", id, "--tail", "20"])).stdout.trim());
1050
+ const workerDead = st.state === "dead" && st.exit_code == null;
1155
1051
  const ok = st.exit_code === 0;
1052
+ const output = await inlineOutput(id, st);
1156
1053
  return {
1157
1054
  id,
1158
1055
  kind: "exited",
1159
1056
  ok,
1160
1057
  text:
1161
1058
  `Process ${id}${meta?.command ? ` (${meta.command})` : ""} ` +
1162
- `${ok ? "completed successfully" : `exited with code ${st.exit_code ?? "?"}`}` +
1059
+ (workerDead
1060
+ ? "worker-dead: the babysit supervisor disappeared without an exit status"
1061
+ : ok ? "completed successfully" : `exited with code ${st.exit_code ?? "?"}`) +
1163
1062
  `${expectPattern ? ` before /${expectPattern}/ appeared` : ""}.` +
1164
- (report
1165
- ? `\n\nReport${report.ok ? "" : " (failed)"}:\n${report.text}`
1166
- : tail ? `\n\nLast output:\n${tail}` : ""),
1063
+ (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."
1065
+ : "") +
1066
+ `\nLog: ${logPath(id)}` + output,
1167
1067
  status: st,
1168
1068
  };
1169
1069
  }
@@ -1210,11 +1110,19 @@ export default function (pi: ExtensionAPI) {
1210
1110
  if (s.state === "running") continue;
1211
1111
  const meta = readMeta(s.id);
1212
1112
  if (!meta || meta.kind !== "process" || meta.notified) continue;
1113
+ // Delay delivery by one poll interval. This gives an agent that chose
1114
+ // babysit_wait immediately after babysit_run enough time to claim the
1115
+ // completion and suppress the otherwise duplicate automatic message.
1116
+ if (!meta.completionObservedAt) {
1117
+ meta.completionObservedAt = Date.now();
1118
+ writeMeta(s.id, meta);
1119
+ continue;
1120
+ }
1121
+ if (Date.now() - meta.completionObservedAt < POLL_MS) continue;
1213
1122
  meta.notified = true;
1214
1123
  writeMeta(s.id, meta);
1215
- const report = meta.report ? await runReport(s.id, meta.report) : null;
1216
- const tail = report ? "" : clip((await bs(["log", "-s", s.id, "--tail", "20"])).stdout.trim());
1217
1124
  const ok = s.exit_code === 0;
1125
+ const output = await inlineOutput(s.id, s);
1218
1126
  const runtime = meta.startedAt
1219
1127
  ? `${Math.round((Date.now() - meta.startedAt) / 1000)}s`
1220
1128
  : "?";
@@ -1227,13 +1135,10 @@ export default function (pi: ExtensionAPI) {
1227
1135
  {
1228
1136
  customType: "pi-babysit-process-end",
1229
1137
  content:
1230
- `${summary}\nCommand: ${meta.command ?? "?"}` +
1231
- (report
1232
- ? `\n\nReport${report.ok ? "" : " (failed)"}:\n${report.text}`
1233
- : tail ? `\n\nRecent output:\n${tail}` : "") +
1234
- `\n\nThis is the automatic process-end notification. Do not call babysit_check just to re-verify; use it once only if you need more logs for debugging.`,
1138
+ `${summary}\nCommand: ${meta.command ?? "?"}\nLog: ${logPath(s.id)}${output}` +
1139
+ `\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.`,
1235
1140
  display: true,
1236
- details: { id: s.id, exitCode: s.exit_code, success: ok, runtime, report },
1141
+ details: { id: s.id, exitCode: s.exit_code, success: ok, runtime, logPath: logPath(s.id) },
1237
1142
  },
1238
1143
  { triggerTurn: true, deliverAs: "steer" },
1239
1144
  );
@@ -1342,22 +1247,25 @@ export default function (pi: ExtensionAPI) {
1342
1247
  name: "babysit_run",
1343
1248
  label: "Babysit: run",
1344
1249
  description:
1345
- "Start a supervised background session under babysit (NON-BLOCKING; returns a session id). " +
1346
- "In non-interactive mode (`pi -p`, no UI) process mode instead BLOCKS until the command " +
1347
- "exits and returns its output inline — there is no notification loop to resume a parked turn. " +
1348
- "Two modes: (1) `command` — run any long-lived or slow command (build, tests, dev server, " +
1349
- "watcher, interactive TUI) in a PTY; you get an AUTOMATIC notification when it exits, and " +
1350
- "you can type into it with babysit_send and read its screen with babysit_check. Optional `report` " +
1351
- "runs local code over the full captured log and returns only its concise output on completion. " +
1250
+ "Run any shell command in a supervised babysit session. Commands that finish within a short " +
1251
+ "grace period return completion metadata immediately; longer commands continue in the background " +
1252
+ "and trigger an automatic notification on exit. Complete output is returned inline only when it is " +
1253
+ "small; larger output stays in the log path for bounded inspection with tail or rg. " +
1254
+ "In non-interactive mode (`pi -p`, no UI), process mode blocks until exit because there is no " +
1255
+ "notification loop. Two modes: (1) `command` — run any shell command, including builds, tests, " +
1256
+ "dev servers, watchers, and interactive TUIs; you can type into it with babysit_send and read " +
1257
+ "its screen with babysit_check. If endpoint security kills a worker at startup, " +
1258
+ "`retryOnWorkerDeath` can retry one idempotent command once. " +
1352
1259
  "(2) `profile: \"subagent\"` + `task` — spawn a pi subagent that works on the task in the " +
1353
1260
  "background; poll with babysit_check, steer with babysit_send, block with babysit_wait, " +
1354
1261
  "stop with babysit_kill.",
1355
1262
  promptSnippet:
1356
- "Run a command or a pi subagent as a supervised background session (non-blocking); returns a session id",
1263
+ "Run any shell command with context-safe captured output; quick commands return metadata, longer ones continue in background",
1357
1264
  promptGuidelines: [
1358
- "Run every long-lived or slow command through babysit_run instead of bash: builds, test suites, dev servers, watchers, `tail -f`, anything expected to take more than a few seconds. Give it a clear stable `name` (e.g. cargo-build).",
1265
+ "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`.",
1266
+ "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.",
1359
1267
  "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').",
1360
- "For a build/test/API command with potentially large output, give babysit_run a `report` program that extracts the needed facts from FILE_CONTENT; the automatic completion notification will contain only that report rather than a raw log tail.",
1268
+ "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.",
1361
1269
  "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 }.",
1362
1270
  "Delegate self-contained tasks (codebase recon, a parallelizable subtask, work that would pollute your context) with babysit_run { profile: \"subagent\", task }. Launch several for independent subtasks; they run concurrently.",
1363
1271
  "After spawning subagents, do not idle-wait and do not end your turn to wait for them: keep making progress, then call babysit_wait (ids + mode any/all) when you need their results. Steer or send follow-up tasks with babysit_send; kill runaways with babysit_kill.",
@@ -1417,20 +1325,13 @@ export default function (pi: ExtensionAPI) {
1417
1325
  "Process mode only. Default false: starting a process ENDS the current turn (you are resumed by the exit notification). Set true only when you have immediate, specific, non-polling work to do after starting.",
1418
1326
  }),
1419
1327
  ),
1420
- report: Type.Optional(
1421
- Type.Object({
1422
- language: StringEnum(["javascript", "python", "shell"] as const, {
1423
- description: "Language for the local report program.",
1424
- }),
1425
- code: Type.String({
1426
- description:
1427
- "Program that analyzes the complete captured log. JavaScript/Python receive FILE_CONTENT and INPUT strings; shell receives $BABYSIT_REPORT_INPUT. Print only the concise report.",
1428
- }),
1429
- timeout: Type.Optional(
1430
- Type.String({ description: "Report-program timeout (default 30s, e.g. '10s')." }),
1431
- ),
1328
+ retryOnWorkerDeath: Type.Optional(
1329
+ Type.Boolean({
1330
+ description:
1331
+ "Process mode only. Retry once if the babysit worker is killed externally during startup. Use only for safe, idempotent commands because the first attempt may have produced side effects.",
1432
1332
  }),
1433
1333
  ),
1334
+
1434
1335
  }),
1435
1336
  async execute(_id, params, _signal, _onUpdate, ctx) {
1436
1337
  await requireBabysit();
@@ -1456,25 +1357,18 @@ export default function (pi: ExtensionAPI) {
1456
1357
  details: {},
1457
1358
  };
1458
1359
  }
1459
- if (isSubagent && params.report) {
1460
- return {
1461
- content: [{ type: "text", text: "`report` is available only for process sessions." }],
1462
- isError: true,
1463
- details: {},
1464
- };
1465
- }
1466
1360
 
1467
1361
  // --- process mode ---
1468
1362
  if (!isSubagent) {
1469
- const res = await spawnProcess({
1363
+ const spawnOpts: ProcOpts = {
1470
1364
  name: params.name,
1471
1365
  command: params.command as string,
1472
1366
  cwd: ctx.cwd,
1473
1367
  timeout: params.timeout,
1474
1368
  idleTimeout: params.idleTimeout,
1475
1369
  pty: params.pty ?? true,
1476
- report: params.report,
1477
- });
1370
+ };
1371
+ let res = await spawnProcess(spawnOpts);
1478
1372
  if ("error" in res) {
1479
1373
  return {
1480
1374
  content: [{ type: "text", text: `Failed to start process: ${res.error}` }],
@@ -1492,11 +1386,46 @@ export default function (pi: ExtensionAPI) {
1492
1386
  // outcome in THIS turn. The process still runs under babysit (logged,
1493
1387
  // killable), we just wait for it here instead of fire-and-forget.
1494
1388
  if (!ctx.hasUI) {
1495
- const outcome = await waitForExit(res.id, parseDurMs(params.timeout), _signal);
1389
+ let outcome = await waitForExit(res.id, parseDurMs(params.timeout), _signal);
1390
+ let retried = false;
1391
+ if (params.retryOnWorkerDeath && outcome.status?.state === "dead" && outcome.status.exit_code == null) {
1392
+ const retry = await spawnProcess(spawnOpts);
1393
+ if (!("error" in retry)) {
1394
+ res = retry;
1395
+ retried = true;
1396
+ outcome = await waitForExit(res.id, parseDurMs(params.timeout), _signal);
1397
+ }
1398
+ }
1496
1399
  return {
1497
- content: [{ type: "text", text: outcome.text }],
1400
+ content: [{ type: "text", text: `${retried ? "Retried once after external worker death.\n" : ""}${outcome.text}` }],
1498
1401
  isError: !outcome.ok,
1499
- details: { id: res.id, kind: "process", command: params.command, report: params.report },
1402
+ details: { id: res.id, kind: "process", command: params.command, logPath: logPath(res.id), retried },
1403
+ };
1404
+ }
1405
+
1406
+ // Keep ordinary quick commands ergonomic. Give the process a short grace
1407
+ // period; if it exits, return only lifecycle metadata + log path now.
1408
+ // A timeout means it is genuinely background work and follows the normal
1409
+ // parked-turn / automatic-notification contract below.
1410
+ await bs(["wait", "-s", res.id, "--timeout", QUICK_COMMAND_GRACE], { signal: _signal });
1411
+ let quickStatus = await statusOf(res.id);
1412
+ let retried = false;
1413
+ if (params.retryOnWorkerDeath && quickStatus?.state === "dead" && quickStatus.exit_code == null) {
1414
+ const retry = await spawnProcess(spawnOpts);
1415
+ if (!("error" in retry)) {
1416
+ res = retry;
1417
+ retried = true;
1418
+ await bs(["wait", "-s", res.id, "--timeout", QUICK_COMMAND_GRACE], { signal: _signal });
1419
+ quickStatus = await statusOf(res.id);
1420
+ }
1421
+ }
1422
+ if (quickStatus && quickStatus.state !== "running") {
1423
+ const outcome = await waitForExit(res.id, null, _signal);
1424
+ await refreshWidget(ctx);
1425
+ return {
1426
+ content: [{ type: "text", text: `${retried ? "Retried once after external worker death.\n" : ""}${outcome.text}` }],
1427
+ isError: !outcome.ok,
1428
+ details: { id: res.id, kind: "process", command: params.command, logPath: logPath(res.id), retried },
1500
1429
  };
1501
1430
  }
1502
1431
 
@@ -1509,13 +1438,14 @@ export default function (pi: ExtensionAPI) {
1509
1438
  {
1510
1439
  type: "text",
1511
1440
  text:
1512
- `Process started (id: ${res.id}). ${NOTIFY_MARKER}\n${nextStep}\n` +
1441
+ `${retried ? "Retried once after external worker death.\n" : ""}` +
1442
+ `Process started (id: ${res.id}). ${NOTIFY_MARKER}\nLog: ${logPath(res.id)}\n${nextStep}\n` +
1513
1443
  `Inspect: babysit_check { id: "${res.id}" } (screen: true for TUIs) · ` +
1514
1444
  `Wait: babysit_wait { id: "${res.id}" } · Kill: babysit_kill { id: "${res.id}" }\n` +
1515
1445
  `Human can watch/take over: /babysit`,
1516
1446
  },
1517
1447
  ],
1518
- details: { id: res.id, kind: "process", command: params.command, report: params.report },
1448
+ details: { id: res.id, kind: "process", command: params.command, logPath: logPath(res.id), retried },
1519
1449
  // Do not return `terminate: true` here. In RPC/subagent hosts that hint
1520
1450
  // can shut down the hosting pi worker, whose process-tree cleanup then
1521
1451
  // kills the otherwise detached babysit supervisor and closes its PTY
@@ -1652,6 +1582,7 @@ export default function (pi: ExtensionAPI) {
1652
1582
  }
1653
1583
  if (st.exit_code != null) header += ` exit_code=${st.exit_code}`;
1654
1584
  if (meta?.command) header += `\ncommand: ${meta.command}`;
1585
+ header += `\nlog: ${logPath(params.id)}`;
1655
1586
  if (st.note) header += ` ⚑ ${st.note}`;
1656
1587
  parts.push(header);
1657
1588
  if (params.screen) {
@@ -1666,7 +1597,7 @@ export default function (pi: ExtensionAPI) {
1666
1597
  }
1667
1598
  return {
1668
1599
  content: [{ type: "text", text: parts.join("\n") }],
1669
- details: { status: st, kind: "process" },
1600
+ details: { status: st, kind: "process", logPath: logPath(params.id) },
1670
1601
  };
1671
1602
  }
1672
1603
 
@@ -1876,62 +1807,6 @@ export default function (pi: ExtensionAPI) {
1876
1807
  },
1877
1808
  });
1878
1809
 
1879
- // ----- babysit_analyze ----------------------------------------------------
1880
- pi.registerTool({
1881
- name: "babysit_analyze",
1882
- label: "Babysit: analyze",
1883
- description:
1884
- "Run a local analyzer over a babysit process's complete captured log. The raw log remains outside " +
1885
- "the model context; only the analyzer's bounded stdout is returned. JavaScript/Python receive " +
1886
- "FILE_CONTENT and INPUT strings; shell receives $BABYSIT_REPORT_INPUT. It can inspect a running " +
1887
- "session's output so far or a completed session.",
1888
- promptSnippet: "Analyze a babysit process log locally and return only a concise report",
1889
- promptGuidelines: [
1890
- "Use babysit_analyze to extract failures, counts, or other concise facts from a large babysit process log instead of calling babysit_check repeatedly or returning raw logs.",
1891
- ],
1892
- parameters: Type.Object({
1893
- id: Type.String({ description: "Process session id whose captured log should be analyzed." }),
1894
- language: StringEnum(["javascript", "python", "shell"] as const, {
1895
- description: "Language for the local analyzer program.",
1896
- }),
1897
- code: Type.String({
1898
- description:
1899
- "Analyzer program. JavaScript/Python receive FILE_CONTENT and INPUT strings; shell reads $BABYSIT_REPORT_INPUT. Print only the concise result.",
1900
- }),
1901
- timeout: Type.Optional(
1902
- Type.String({ description: "Analyzer timeout (default 30s, e.g. '10s')." }),
1903
- ),
1904
- }),
1905
- async execute(_id, params, signal) {
1906
- await requireBabysit();
1907
- const status = await statusOf(params.id);
1908
- if (!status) {
1909
- return {
1910
- content: [{ type: "text", text: `No such session: ${params.id}` }],
1911
- isError: true,
1912
- details: {},
1913
- };
1914
- }
1915
- if (kindOf(params.id) === "subagent") {
1916
- return {
1917
- content: [{ type: "text", text: "babysit_analyze supports process sessions only; use babysit_check for subagent progress." }],
1918
- isError: true,
1919
- details: {},
1920
- };
1921
- }
1922
- const report = await runReport(params.id, {
1923
- language: params.language,
1924
- code: params.code,
1925
- timeout: params.timeout,
1926
- }, signal);
1927
- return {
1928
- content: [{ type: "text", text: report.text }],
1929
- isError: !report.ok,
1930
- details: {},
1931
- };
1932
- },
1933
- });
1934
-
1935
1810
  // ----- babysit_wait -------------------------------------------------------
1936
1811
  pi.registerTool({
1937
1812
  name: "babysit_wait",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.2.4",
4
- "description": "Run long-lived processes and pi subagents under babysit, with bounded local reports over captured logs.",
3
+ "version": "0.3.1",
4
+ "description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi-extension",