@yusukeshib/pi-babysit 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +17 -1
  2. package/index.ts +85 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -49,7 +49,7 @@ programs** (installers, wizards, REPLs): type with `babysit_send`
49
49
 
50
50
  | Tool | What it does |
51
51
  | ---- | ------------ |
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
+ | `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
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) |
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"` |
@@ -84,6 +84,22 @@ rg -n 'FAIL|ERROR' /path/to/output.log
84
84
  `babysit_check { id, lines }` remains available as a convenient bounded tail.
85
85
  Do not read a 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.
93
+
94
+ ## External worker death
95
+
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.
102
+
87
103
  ## How completion detection works
88
104
 
89
105
  - **Process**: a 2.5s poller watches for running→exited transitions and injects
package/index.ts CHANGED
@@ -1047,6 +1047,7 @@ async function waitForExit(
1047
1047
  }
1048
1048
  suppressNotify(id); // the agent sees the exit here; don't notify again
1049
1049
  const meta = readMeta(id);
1050
+ const workerDead = st.state === "dead" && st.exit_code == null;
1050
1051
  const ok = st.exit_code === 0;
1051
1052
  const output = await inlineOutput(id, st);
1052
1053
  return {
@@ -1055,8 +1056,13 @@ async function waitForExit(
1055
1056
  ok,
1056
1057
  text:
1057
1058
  `Process ${id}${meta?.command ? ` (${meta.command})` : ""} ` +
1058
- `${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 ?? "?"}`) +
1059
1062
  `${expectPattern ? ` before /${expectPattern}/ appeared` : ""}.` +
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
+ : "") +
1060
1066
  `\nLog: ${logPath(id)}` + output,
1061
1067
  status: st,
1062
1068
  };
@@ -1086,6 +1092,30 @@ function backgroundsItself(command: string): boolean {
1086
1092
  return false;
1087
1093
  }
1088
1094
 
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;
1117
+ }
1118
+
1089
1119
  // ---------------------------------------------------------------------------
1090
1120
  // extension
1091
1121
  // ---------------------------------------------------------------------------
@@ -1222,17 +1252,27 @@ export default function (pi: ExtensionAPI) {
1222
1252
  pollTimer = undefined;
1223
1253
  });
1224
1254
 
1225
- // Block bash commands that background themselves — they belong in
1226
- // babysit_run (which supervises, logs, and notifies on exit).
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.
1227
1258
  pi.on("tool_call", async (event) => {
1228
1259
  if (event.toolName !== "bash") return;
1229
1260
  const command = String((event.input as { command?: unknown }).command ?? "");
1230
- if (!backgroundsItself(command)) return;
1261
+ if (backgroundsItself(command)) {
1262
+ return {
1263
+ block: true,
1264
+ reason:
1265
+ `This bash command tries to run in the background. Use babysit_run instead, e.g. ` +
1266
+ `babysit_run({ name: "background-process", command: ${JSON.stringify(command.replace(/\s*&\s*$/, ""))} })`,
1267
+ };
1268
+ }
1269
+ if (isAllowedDirectBash(command)) return;
1231
1270
  return {
1232
1271
  block: true,
1233
1272
  reason:
1234
- `This bash command tries to run in the background. Use babysit_run instead, e.g. ` +
1235
- `babysit_run({ name: "background-process", command: ${JSON.stringify(command.replace(/\s*&\s*$/, ""))} })`,
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. " +
1275
+ `Retry as babysit_run({ command: ${JSON.stringify(command)} }).`,
1236
1276
  };
1237
1277
  });
1238
1278
 
@@ -1248,8 +1288,8 @@ export default function (pi: ExtensionAPI) {
1248
1288
  "In non-interactive mode (`pi -p`, no UI), process mode blocks until exit because there is no " +
1249
1289
  "notification loop. Two modes: (1) `command` — run any shell command, including builds, tests, " +
1250
1290
  "dev servers, watchers, and interactive TUIs; you can type into it with babysit_send and read " +
1251
- "its screen with babysit_check. " +
1252
- "you can type into it with babysit_send and read its screen with babysit_check. " +
1291
+ "its screen with babysit_check. If endpoint security kills a worker at startup, " +
1292
+ "`retryOnWorkerDeath` can retry one idempotent command once. " +
1253
1293
  "(2) `profile: \"subagent\"` + `task` — spawn a pi subagent that works on the task in the " +
1254
1294
  "background; poll with babysit_check, steer with babysit_send, block with babysit_wait, " +
1255
1295
  "stop with babysit_kill.",
@@ -1259,7 +1299,7 @@ export default function (pi: ExtensionAPI) {
1259
1299
  "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
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.",
1261
1301
  "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.",
1302
+ "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.",
1263
1303
  "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 }.",
1264
1304
  "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.",
1265
1305
  "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.",
@@ -1319,6 +1359,12 @@ export default function (pi: ExtensionAPI) {
1319
1359
  "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.",
1320
1360
  }),
1321
1361
  ),
1362
+ retryOnWorkerDeath: Type.Optional(
1363
+ Type.Boolean({
1364
+ description:
1365
+ "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.",
1366
+ }),
1367
+ ),
1322
1368
 
1323
1369
  }),
1324
1370
  async execute(_id, params, _signal, _onUpdate, ctx) {
@@ -1348,14 +1394,15 @@ export default function (pi: ExtensionAPI) {
1348
1394
 
1349
1395
  // --- process mode ---
1350
1396
  if (!isSubagent) {
1351
- const res = await spawnProcess({
1397
+ const spawnOpts: ProcOpts = {
1352
1398
  name: params.name,
1353
1399
  command: params.command as string,
1354
1400
  cwd: ctx.cwd,
1355
1401
  timeout: params.timeout,
1356
1402
  idleTimeout: params.idleTimeout,
1357
1403
  pty: params.pty ?? true,
1358
- });
1404
+ };
1405
+ let res = await spawnProcess(spawnOpts);
1359
1406
  if ("error" in res) {
1360
1407
  return {
1361
1408
  content: [{ type: "text", text: `Failed to start process: ${res.error}` }],
@@ -1373,11 +1420,20 @@ export default function (pi: ExtensionAPI) {
1373
1420
  // outcome in THIS turn. The process still runs under babysit (logged,
1374
1421
  // killable), we just wait for it here instead of fire-and-forget.
1375
1422
  if (!ctx.hasUI) {
1376
- const outcome = await waitForExit(res.id, parseDurMs(params.timeout), _signal);
1423
+ let outcome = await waitForExit(res.id, parseDurMs(params.timeout), _signal);
1424
+ let retried = false;
1425
+ if (params.retryOnWorkerDeath && outcome.status?.state === "dead" && outcome.status.exit_code == null) {
1426
+ const retry = await spawnProcess(spawnOpts);
1427
+ if (!("error" in retry)) {
1428
+ res = retry;
1429
+ retried = true;
1430
+ outcome = await waitForExit(res.id, parseDurMs(params.timeout), _signal);
1431
+ }
1432
+ }
1377
1433
  return {
1378
- content: [{ type: "text", text: outcome.text }],
1434
+ content: [{ type: "text", text: `${retried ? "Retried once after external worker death.\n" : ""}${outcome.text}` }],
1379
1435
  isError: !outcome.ok,
1380
- details: { id: res.id, kind: "process", command: params.command, logPath: logPath(res.id) },
1436
+ details: { id: res.id, kind: "process", command: params.command, logPath: logPath(res.id), retried },
1381
1437
  };
1382
1438
  }
1383
1439
 
@@ -1386,14 +1442,24 @@ export default function (pi: ExtensionAPI) {
1386
1442
  // A timeout means it is genuinely background work and follows the normal
1387
1443
  // parked-turn / automatic-notification contract below.
1388
1444
  await bs(["wait", "-s", res.id, "--timeout", QUICK_COMMAND_GRACE], { signal: _signal });
1389
- const quickStatus = await statusOf(res.id);
1445
+ let quickStatus = await statusOf(res.id);
1446
+ let retried = false;
1447
+ if (params.retryOnWorkerDeath && quickStatus?.state === "dead" && quickStatus.exit_code == null) {
1448
+ const retry = await spawnProcess(spawnOpts);
1449
+ if (!("error" in retry)) {
1450
+ res = retry;
1451
+ retried = true;
1452
+ await bs(["wait", "-s", res.id, "--timeout", QUICK_COMMAND_GRACE], { signal: _signal });
1453
+ quickStatus = await statusOf(res.id);
1454
+ }
1455
+ }
1390
1456
  if (quickStatus && quickStatus.state !== "running") {
1391
1457
  const outcome = await waitForExit(res.id, null, _signal);
1392
1458
  await refreshWidget(ctx);
1393
1459
  return {
1394
- content: [{ type: "text", text: outcome.text }],
1460
+ content: [{ type: "text", text: `${retried ? "Retried once after external worker death.\n" : ""}${outcome.text}` }],
1395
1461
  isError: !outcome.ok,
1396
- details: { id: res.id, kind: "process", command: params.command, logPath: logPath(res.id) },
1462
+ details: { id: res.id, kind: "process", command: params.command, logPath: logPath(res.id), retried },
1397
1463
  };
1398
1464
  }
1399
1465
 
@@ -1406,13 +1472,14 @@ export default function (pi: ExtensionAPI) {
1406
1472
  {
1407
1473
  type: "text",
1408
1474
  text:
1475
+ `${retried ? "Retried once after external worker death.\n" : ""}` +
1409
1476
  `Process started (id: ${res.id}). ${NOTIFY_MARKER}\nLog: ${logPath(res.id)}\n${nextStep}\n` +
1410
1477
  `Inspect: babysit_check { id: "${res.id}" } (screen: true for TUIs) · ` +
1411
1478
  `Wait: babysit_wait { id: "${res.id}" } · Kill: babysit_kill { id: "${res.id}" }\n` +
1412
1479
  `Human can watch/take over: /babysit`,
1413
1480
  },
1414
1481
  ],
1415
- details: { id: res.id, kind: "process", command: params.command, logPath: logPath(res.id) },
1482
+ details: { id: res.id, kind: "process", command: params.command, logPath: logPath(res.id), retried },
1416
1483
  // Do not return `terminate: true` here. In RPC/subagent hosts that hint
1417
1484
  // can shut down the hosting pi worker, whose process-tree cleanup then
1418
1485
  // kills the otherwise detached babysit supervisor and closes its PTY
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
5
5
  "keywords": [
6
6
  "pi-package",