@yusukeshib/pi-babysit 0.2.3 → 0.3.0

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 +21 -4
  2. package/index.ts +94 -26
  3. package/package.json +3 -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,7 +49,7 @@ 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`) 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
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`) |
54
55
  | `babysit_wait` | Block until done: process exit (or `expect: "regex"` readiness marker), subagent task completion. Multi-wait: `ids` + `mode: "any"\|"all"` |
@@ -67,6 +68,22 @@ A `tool_call` hook also blocks bash commands that background themselves
67
68
  A minimal widget above the editor shows live counts
68
69
  (`N processes · M subagents working · K idle`).
69
70
 
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
82
+ ```
83
+
84
+ `babysit_check { id, lines }` remains available as a convenient bounded tail.
85
+ Do not read a potentially large log file in full.
86
+
70
87
  ## How completion detection works
71
88
 
72
89
  - **Process**: a 2.5s poller watches for running→exited transitions and injects
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,
@@ -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;
@@ -231,6 +232,7 @@ interface Meta {
231
232
  name?: string;
232
233
  command?: string;
233
234
  notified?: boolean;
235
+ completionObservedAt?: number;
234
236
  startedAt?: number;
235
237
  // subagent
236
238
  task?: string;
@@ -239,6 +241,7 @@ interface Meta {
239
241
  }
240
242
 
241
243
  const metaDir = () => path.join(ROOT, "meta");
244
+ const logPath = (id: string) => path.join(ROOT, "sessions", id, "output.log");
242
245
 
243
246
  function writeMeta(id: string, m: Meta): void {
244
247
  try {
@@ -364,7 +367,8 @@ function parseDurMs(s?: string): number | null {
364
367
  // megabytes, so we also cap bytes, eliding the middle so both the head and
365
368
  // the tail of the output stay visible.
366
369
 
367
- 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
368
372
  const ANSWER_MAX_BYTES = 24_000; // subagent answers / error messages
369
373
 
370
374
  function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
@@ -377,6 +381,27 @@ function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
377
381
  return `${head}\n… [${buf.length - maxBytes} bytes elided] …\n${tail}`;
378
382
  }
379
383
 
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).`;
400
+ }
401
+ return output ? `\n\nOutput:\n${output}` : "";
402
+ }
403
+
404
+
380
405
  // ---------------------------------------------------------------------------
381
406
  // parked-turn detection (shared rule with self-reap.ts)
382
407
  // ---------------------------------------------------------------------------
@@ -830,7 +855,7 @@ interface WaitOutcome {
830
855
  // Wait for ONE subagent's current task. Completion = an agent_end whose last
831
856
  // message is NOT a parked babysit_run/process toolResult (that one only means
832
857
  // "turn parked, waiting for a background process — pi resumes on its own").
833
- // Loop: analyze the current-task log slice; done → report; still going →
858
+ // Loop: analyze the current-task log slice; done → return; still going →
834
859
  // block on the next agent_end via `babysit expect` (race-free byte offsets).
835
860
  async function waitForTask(
836
861
  id: string,
@@ -949,6 +974,14 @@ function suppressNotify(id: string): void {
949
974
  }
950
975
  }
951
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
+
952
985
  // Wait for a PROCESS session: either until a regex appears in its output
953
986
  // (`expect` — e.g. "server listening") or until the process exits.
954
987
  async function waitForExit(
@@ -965,12 +998,11 @@ async function waitForExit(
965
998
  return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
966
999
  }
967
1000
  if (e.code === 0) {
968
- const tail = clip((await bs(["log", "-s", id, "--tail", "10"])).stdout.trim());
969
1001
  return {
970
1002
  id,
971
1003
  kind: "done",
972
1004
  ok: true,
973
- 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)}`,
974
1006
  };
975
1007
  }
976
1008
  const st0 = await statusOf(id);
@@ -985,14 +1017,19 @@ async function waitForExit(
985
1017
  }
986
1018
  // fall through: session exited before the pattern appeared
987
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);
988
1023
  const w = await bs(["wait", "-s", id, "--timeout", t], { signal });
989
1024
  if (signal?.aborted || w.code === 130) {
1025
+ enableNotify(id);
990
1026
  return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
991
1027
  }
992
1028
  if (w.code === 124) {
993
1029
  // 124 is ambiguous (timeout vs child exiting 124) — disambiguate.
994
1030
  const st0 = await statusOf(id);
995
1031
  if (st0?.state === "running") {
1032
+ enableNotify(id);
996
1033
  return {
997
1034
  id,
998
1035
  kind: "timeout",
@@ -1009,9 +1046,9 @@ async function waitForExit(
1009
1046
  return { id, kind: "exited", ok: false, text: `No such session: ${id}` };
1010
1047
  }
1011
1048
  suppressNotify(id); // the agent sees the exit here; don't notify again
1012
- const tail = clip((await bs(["log", "-s", id, "--tail", "20"])).stdout.trim());
1013
- const ok = st.exit_code === 0;
1014
1049
  const meta = readMeta(id);
1050
+ const ok = st.exit_code === 0;
1051
+ const output = await inlineOutput(id, st);
1015
1052
  return {
1016
1053
  id,
1017
1054
  kind: "exited",
@@ -1020,7 +1057,7 @@ async function waitForExit(
1020
1057
  `Process ${id}${meta?.command ? ` (${meta.command})` : ""} ` +
1021
1058
  `${ok ? "completed successfully" : `exited with code ${st.exit_code ?? "?"}`}` +
1022
1059
  `${expectPattern ? ` before /${expectPattern}/ appeared` : ""}.` +
1023
- (tail ? `\n\nLast output:\n${tail}` : ""),
1060
+ `\nLog: ${logPath(id)}` + output,
1024
1061
  status: st,
1025
1062
  };
1026
1063
  }
@@ -1067,10 +1104,19 @@ export default function (pi: ExtensionAPI) {
1067
1104
  if (s.state === "running") continue;
1068
1105
  const meta = readMeta(s.id);
1069
1106
  if (!meta || meta.kind !== "process" || meta.notified) continue;
1107
+ // Delay delivery by one poll interval. This gives an agent that chose
1108
+ // babysit_wait immediately after babysit_run enough time to claim the
1109
+ // completion and suppress the otherwise duplicate automatic message.
1110
+ if (!meta.completionObservedAt) {
1111
+ meta.completionObservedAt = Date.now();
1112
+ writeMeta(s.id, meta);
1113
+ continue;
1114
+ }
1115
+ if (Date.now() - meta.completionObservedAt < POLL_MS) continue;
1070
1116
  meta.notified = true;
1071
1117
  writeMeta(s.id, meta);
1072
- const tail = clip((await bs(["log", "-s", s.id, "--tail", "20"])).stdout.trim());
1073
1118
  const ok = s.exit_code === 0;
1119
+ const output = await inlineOutput(s.id, s);
1074
1120
  const runtime = meta.startedAt
1075
1121
  ? `${Math.round((Date.now() - meta.startedAt) / 1000)}s`
1076
1122
  : "?";
@@ -1083,11 +1129,10 @@ export default function (pi: ExtensionAPI) {
1083
1129
  {
1084
1130
  customType: "pi-babysit-process-end",
1085
1131
  content:
1086
- `${summary}\nCommand: ${meta.command ?? "?"}` +
1087
- (tail ? `\n\nRecent output:\n${tail}` : "") +
1088
- `\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.`,
1132
+ `${summary}\nCommand: ${meta.command ?? "?"}\nLog: ${logPath(s.id)}${output}` +
1133
+ `\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.`,
1089
1134
  display: true,
1090
- details: { id: s.id, exitCode: s.exit_code, success: ok, runtime },
1135
+ details: { id: s.id, exitCode: s.exit_code, success: ok, runtime, logPath: logPath(s.id) },
1091
1136
  },
1092
1137
  { triggerTurn: true, deliverAs: "steer" },
1093
1138
  );
@@ -1196,20 +1241,25 @@ export default function (pi: ExtensionAPI) {
1196
1241
  name: "babysit_run",
1197
1242
  label: "Babysit: run",
1198
1243
  description:
1199
- "Start a supervised background session under babysit (NON-BLOCKING; returns a session id). " +
1200
- "In non-interactive mode (`pi -p`, no UI) process mode instead BLOCKS until the command " +
1201
- "exits and returns its output inline — there is no notification loop to resume a parked turn. " +
1202
- "Two modes: (1) `command` — run any long-lived or slow command (build, tests, dev server, " +
1203
- "watcher, interactive TUI) in a PTY; you get an AUTOMATIC notification when it exits, and " +
1244
+ "Run any shell command in a supervised babysit session. Commands that finish within a short " +
1245
+ "grace period return completion metadata immediately; longer commands continue in the background " +
1246
+ "and trigger an automatic notification on exit. Complete output is returned inline only when it is " +
1247
+ "small; larger output stays in the log path for bounded inspection with tail or rg. " +
1248
+ "In non-interactive mode (`pi -p`, no UI), process mode blocks until exit because there is no " +
1249
+ "notification loop. Two modes: (1) `command` — run any shell command, including builds, tests, " +
1250
+ "dev servers, watchers, and interactive TUIs; you can type into it with babysit_send and read " +
1251
+ "its screen with babysit_check. " +
1204
1252
  "you can type into it with babysit_send and read its screen with babysit_check. " +
1205
1253
  "(2) `profile: \"subagent\"` + `task` — spawn a pi subagent that works on the task in the " +
1206
1254
  "background; poll with babysit_check, steer with babysit_send, block with babysit_wait, " +
1207
1255
  "stop with babysit_kill.",
1208
1256
  promptSnippet:
1209
- "Run a command or a pi subagent as a supervised background session (non-blocking); returns a session id",
1257
+ "Run any shell command with context-safe captured output; quick commands return metadata, longer ones continue in background",
1210
1258
  promptGuidelines: [
1211
- "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).",
1259
+ "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`.",
1260
+ "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.",
1212
1261
  "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').",
1262
+ "babysit_run returns only minimal lifecycle information and a path to the complete log. Inspect that file with bounded shell commands such as `tail` or `rg`; never read a potentially large log in full.",
1213
1263
  "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 }.",
1214
1264
  "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.",
1215
1265
  "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.",
@@ -1269,6 +1319,7 @@ export default function (pi: ExtensionAPI) {
1269
1319
  "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.",
1270
1320
  }),
1271
1321
  ),
1322
+
1272
1323
  }),
1273
1324
  async execute(_id, params, _signal, _onUpdate, ctx) {
1274
1325
  await requireBabysit();
@@ -1304,7 +1355,7 @@ export default function (pi: ExtensionAPI) {
1304
1355
  timeout: params.timeout,
1305
1356
  idleTimeout: params.idleTimeout,
1306
1357
  pty: params.pty ?? true,
1307
- });
1358
+ });
1308
1359
  if ("error" in res) {
1309
1360
  return {
1310
1361
  content: [{ type: "text", text: `Failed to start process: ${res.error}` }],
@@ -1326,7 +1377,23 @@ export default function (pi: ExtensionAPI) {
1326
1377
  return {
1327
1378
  content: [{ type: "text", text: outcome.text }],
1328
1379
  isError: !outcome.ok,
1329
- details: { id: res.id, kind: "process", command: params.command },
1380
+ details: { id: res.id, kind: "process", command: params.command, logPath: logPath(res.id) },
1381
+ };
1382
+ }
1383
+
1384
+ // Keep ordinary quick commands ergonomic. Give the process a short grace
1385
+ // period; if it exits, return only lifecycle metadata + log path now.
1386
+ // A timeout means it is genuinely background work and follows the normal
1387
+ // parked-turn / automatic-notification contract below.
1388
+ await bs(["wait", "-s", res.id, "--timeout", QUICK_COMMAND_GRACE], { signal: _signal });
1389
+ const quickStatus = await statusOf(res.id);
1390
+ if (quickStatus && quickStatus.state !== "running") {
1391
+ const outcome = await waitForExit(res.id, null, _signal);
1392
+ await refreshWidget(ctx);
1393
+ return {
1394
+ content: [{ type: "text", text: outcome.text }],
1395
+ isError: !outcome.ok,
1396
+ details: { id: res.id, kind: "process", command: params.command, logPath: logPath(res.id) },
1330
1397
  };
1331
1398
  }
1332
1399
 
@@ -1339,13 +1406,13 @@ export default function (pi: ExtensionAPI) {
1339
1406
  {
1340
1407
  type: "text",
1341
1408
  text:
1342
- `Process started (id: ${res.id}). ${NOTIFY_MARKER}\n${nextStep}\n` +
1409
+ `Process started (id: ${res.id}). ${NOTIFY_MARKER}\nLog: ${logPath(res.id)}\n${nextStep}\n` +
1343
1410
  `Inspect: babysit_check { id: "${res.id}" } (screen: true for TUIs) · ` +
1344
1411
  `Wait: babysit_wait { id: "${res.id}" } · Kill: babysit_kill { id: "${res.id}" }\n` +
1345
1412
  `Human can watch/take over: /babysit`,
1346
1413
  },
1347
1414
  ],
1348
- details: { id: res.id, kind: "process", command: params.command },
1415
+ details: { id: res.id, kind: "process", command: params.command, logPath: logPath(res.id) },
1349
1416
  // Do not return `terminate: true` here. In RPC/subagent hosts that hint
1350
1417
  // can shut down the hosting pi worker, whose process-tree cleanup then
1351
1418
  // kills the otherwise detached babysit supervisor and closes its PTY
@@ -1482,6 +1549,7 @@ export default function (pi: ExtensionAPI) {
1482
1549
  }
1483
1550
  if (st.exit_code != null) header += ` exit_code=${st.exit_code}`;
1484
1551
  if (meta?.command) header += `\ncommand: ${meta.command}`;
1552
+ header += `\nlog: ${logPath(params.id)}`;
1485
1553
  if (st.note) header += ` ⚑ ${st.note}`;
1486
1554
  parts.push(header);
1487
1555
  if (params.screen) {
@@ -1496,7 +1564,7 @@ export default function (pi: ExtensionAPI) {
1496
1564
  }
1497
1565
  return {
1498
1566
  content: [{ type: "text", text: parts.join("\n") }],
1499
- details: { status: st, kind: "process" },
1567
+ details: { status: st, kind: "process", logPath: logPath(params.id) },
1500
1568
  };
1501
1569
  }
1502
1570
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.2.3",
4
- "description": "Run anything long-lived under babysit — one supervision substrate for processes and pi subagents, surfaced to the agent as babysit_run/check/send/wait/kill.",
3
+ "version": "0.3.0",
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",
@@ -27,6 +27,7 @@
27
27
  "LICENSE"
28
28
  ],
29
29
  "scripts": {
30
+ "test": "bun test",
30
31
  "release": "npm publish --access public"
31
32
  },
32
33
  "pi": {