@yusukeshib/pi-babysit 0.3.6 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +9 -4
  2. package/index.ts +185 -39
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -53,7 +53,7 @@ programs** (installers, wizards, REPLs): type with `babysit_send`
53
53
  | `babysit_check` | List all sessions, inspect one, tail its bounded recent output, or search its raw log with `pattern`; `screen: true` captures TUIs and subagents otherwise show structured live progress |
54
54
  | `babysit_send` | Process: type `text` / press `keys` into the PTY. Subagent: steer mid-run, or send a follow-up task when idle (`mode: auto/steer/task`) |
55
55
  | `babysit_wait` | Block until done: process exit (or `expect: "regex"` readiness marker), subagent task completion. Multi-wait: `ids` + `mode: "any"\|"all"` |
56
- | `babysit_kill` | Terminate a session (suppresses the exit notification) |
56
+ | `babysit_kill` | Terminate a session, verify terminal state, then suppress the exit notification |
57
57
 
58
58
  A `tool_call` hook blocks shell backgrounding (`… &`, `nohup`, `setsid`,
59
59
  `disown`) and redirects all direct `bash` commands to `babysit_run`.
@@ -71,9 +71,10 @@ A minimal widget above the editor shows live counts
71
71
 
72
72
  `babysit_run`, `babysit_wait`, and automatic completion notifications always
73
73
  return lifecycle metadata and the absolute path to the complete `output.log`.
74
- When the complete output is at most 8 KB it is returned inline; larger output
75
- stays out of model context. Inspect it through the session id without creating
76
- another shell session:
74
+ Explicit run/wait results inline complete output up to 8 KB; unsolicited
75
+ completion notifications use a stricter 2 KB cap. Larger output stays out of
76
+ model context. Inspect it through the session id without creating another shell
77
+ session:
77
78
 
78
79
  ```text
79
80
  babysit_check { id: "cargo-test", lines: 50 }
@@ -123,6 +124,10 @@ rule, so a subagent waiting on a long build is never false-killed.
123
124
  | `PI_BABYSIT_CLI` | `babysit` | babysit binary |
124
125
  | `PI_BABYSIT_VIEW_CMD` | bundled `format-stream.mjs` | live-attach pretty printer for subagent JSONL (`""` disables) |
125
126
  | `PI_BABYSIT_REAP_AFTER` | `120s` | idle grace before a finished subagent self-exits (`off`/`none`/`0` disables) |
127
+ | `PI_BABYSIT_TAIL_MAX_BYTES` | `8000` | cap for explicit log tails/screens returned by `babysit_check` |
128
+ | `PI_BABYSIT_INLINE_OUTPUT_MAX_BYTES` | `8000` | cap for complete output in explicitly requested run/wait results |
129
+ | `PI_BABYSIT_NOTIFY_OUTPUT_MAX_BYTES` | `2000` | smaller cap for unsolicited process-completion notifications (`0` omits all output) |
130
+ | `PI_BABYSIT_NOTIFY_COMMAND_MAX_BYTES` | `240` | cap for the command preview in completion notifications |
126
131
  | `PI_BABYSIT_ALLOW_BASH` | unset | set to `1` to bypass direct-Bash redirection (emergency escape hatch) |
127
132
 
128
133
  Requires `babysit` 0.13.0 or newer and `pi` on `PATH`. The extension does **not**
package/index.ts CHANGED
@@ -81,6 +81,7 @@ const SUBAGENT_GUIDANCE = [
81
81
  ].join(" ");
82
82
  const POLL_MS = 2500;
83
83
  const QUICK_COMMAND_GRACE = process.env.PI_BABYSIT_QUICK_GRACE ?? "1s";
84
+ const KILL_CONFIRM_TIMEOUT = "4s";
84
85
 
85
86
  interface BsSession {
86
87
  id: string;
@@ -241,6 +242,34 @@ async function statusOf(id: string): Promise<BsSession | null> {
241
242
  }
242
243
  }
243
244
 
245
+ export function isConfirmedTerminalState(state: string): boolean {
246
+ return state === "killed" || state === "exited";
247
+ }
248
+
249
+ export function validateKillResponse(stdout: string): string | null {
250
+ try {
251
+ const response = JSON.parse(stdout);
252
+ if (response.killed !== true || response.confirmed === false) {
253
+ return `Kill was not confirmed by babysit: ${stdout.trim()}`;
254
+ }
255
+ return null;
256
+ } catch {
257
+ return `Invalid kill response from babysit: ${stdout.trim() || "(empty)"}`;
258
+ }
259
+ }
260
+
261
+ async function awaitConfirmedTermination(id: string): Promise<BsSession | null> {
262
+ const initial = await statusOf(id);
263
+ if (!initial || isConfirmedTerminalState(initial.state) || initial.state === "dead") {
264
+ return initial;
265
+ }
266
+ // New babysit versions return only after persistence, so this is normally
267
+ // skipped. It is a bounded compatibility guard for older binaries that
268
+ // acknowledged signal delivery before the process actually exited.
269
+ await bs(["wait", "-s", id, "--timeout", KILL_CONFIRM_TIMEOUT]);
270
+ return statusOf(id);
271
+ }
272
+
244
273
  // ---------------------------------------------------------------------------
245
274
  // per-session metadata
246
275
  // ---------------------------------------------------------------------------
@@ -254,6 +283,12 @@ interface Meta {
254
283
  name?: string;
255
284
  command?: string;
256
285
  notified?: boolean;
286
+ // A confirmed kill permanently owns completion delivery. An interrupted
287
+ // concurrent wait must not re-enable the automatic notification afterward.
288
+ killNotificationSuppressed?: boolean;
289
+ // Temporary reservation while kill is in flight. Unlike `notified`, this
290
+ // must be cleared on failure so a real completion remains deliverable.
291
+ notificationPaused?: boolean;
257
292
  completionObservedAt?: number;
258
293
  startedAt?: number;
259
294
  // subagent
@@ -389,8 +424,19 @@ function parseDurMs(s?: string): number | null {
389
424
  // megabytes, so we also cap bytes, eliding the middle so both the head and
390
425
  // the tail of the output stay visible.
391
426
 
392
- const TAIL_MAX_BYTES = 8_000; // explicit tails / screens
393
- const INLINE_OUTPUT_MAX_BYTES = 8_000; // complete output returned only below this threshold
427
+ function byteLimitFromEnv(name: string, fallback: number): number {
428
+ const raw = process.env[name];
429
+ if (raw == null || raw.trim() === "") return fallback;
430
+ const value = Number(raw);
431
+ return Number.isSafeInteger(value) && value >= 0 ? value : fallback;
432
+ }
433
+
434
+ const TAIL_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_TAIL_MAX_BYTES", 8_000);
435
+ // Direct run/wait results can carry more context because the caller explicitly
436
+ // requested them. Unsolicited completion notifications default much smaller.
437
+ const INLINE_OUTPUT_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_INLINE_OUTPUT_MAX_BYTES", 8_000);
438
+ const NOTIFY_OUTPUT_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_OUTPUT_MAX_BYTES", 2_000);
439
+ const NOTIFY_COMMAND_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_COMMAND_MAX_BYTES", 240);
394
440
  const ANSWER_MAX_BYTES = 24_000; // subagent answers / error messages
395
441
 
396
442
  function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
@@ -465,7 +511,15 @@ async function searchLog(
465
511
  });
466
512
  }
467
513
 
468
- async function inlineOutput(id: string, status: BsSession): Promise<string> {
514
+ export function shouldInlineCompleteOutput(outputBytes: number, maxBytes: number): boolean {
515
+ return maxBytes > 0 && outputBytes <= maxBytes;
516
+ }
517
+
518
+ async function inlineOutput(
519
+ id: string,
520
+ status: BsSession,
521
+ maxBytes = INLINE_OUTPUT_MAX_BYTES,
522
+ ): Promise<string> {
469
523
  let bytes = status.output_bytes;
470
524
  if (bytes == null) {
471
525
  try {
@@ -474,17 +528,38 @@ async function inlineOutput(id: string, status: BsSession): Promise<string> {
474
528
  bytes = Number.POSITIVE_INFINITY;
475
529
  }
476
530
  }
477
- if (bytes > INLINE_OUTPUT_MAX_BYTES) {
531
+ if (!shouldInlineCompleteOutput(bytes, maxBytes)) {
478
532
  const size = Number.isFinite(bytes) ? `${bytes} bytes` : "size unavailable";
479
- return `\nOutput omitted (${size}; inline limit ${INLINE_OUTPUT_MAX_BYTES}).`;
533
+ return `\nOutput omitted (${size}; inline limit ${maxBytes}).`;
480
534
  }
481
535
  const output = (await bs(["log", "-s", id])).stdout.trimEnd();
482
- if (Buffer.byteLength(output) > INLINE_OUTPUT_MAX_BYTES) {
483
- return `\nOutput omitted (exceeds inline limit ${INLINE_OUTPUT_MAX_BYTES} bytes).`;
536
+ if (Buffer.byteLength(output) > maxBytes) {
537
+ return `\nOutput omitted (exceeds inline limit ${maxBytes} bytes).`;
484
538
  }
485
539
  return output ? `\n\nOutput:\n${output}` : "";
486
540
  }
487
541
 
542
+ export function summarizeNotificationCommand(command: string | undefined): string {
543
+ const preview =
544
+ (command ?? "?")
545
+ .trim()
546
+ .replace(/\r/g, "\\r")
547
+ .replace(/\n/g, "\\n")
548
+ .replace(/\t/g, "\\t") || "?";
549
+ const bytes = Buffer.from(preview, "utf8");
550
+ if (bytes.length <= NOTIFY_COMMAND_MAX_BYTES) return preview;
551
+ if (NOTIFY_COMMAND_MAX_BYTES === 0) return "";
552
+ const ellipsis = Buffer.from("…", "utf8");
553
+ if (NOTIFY_COMMAND_MAX_BYTES <= ellipsis.length) {
554
+ return ".".repeat(NOTIFY_COMMAND_MAX_BYTES);
555
+ }
556
+ const prefix = bytes
557
+ .subarray(0, NOTIFY_COMMAND_MAX_BYTES - ellipsis.length)
558
+ .toString("utf8")
559
+ .replace(/\uFFFD+$/, "");
560
+ return `${prefix}…`;
561
+ }
562
+
488
563
 
489
564
  // ---------------------------------------------------------------------------
490
565
  // parked-turn detection (shared rule with self-reap.ts)
@@ -1050,22 +1125,47 @@ async function waitForTask(
1050
1125
 
1051
1126
  // Mark a process session as already-reported so the exit-notification poller
1052
1127
  // doesn't send a duplicate message for something the agent just observed.
1053
- function suppressNotify(id: string): void {
1128
+ function suppressNotify(id: string, reason: "observed" | "kill" = "observed"): void {
1054
1129
  const meta = readMeta(id);
1055
- if (meta && meta.kind === "process" && !meta.notified) {
1130
+ if (meta && meta.kind === "process") {
1056
1131
  meta.notified = true;
1132
+ if (reason === "kill") meta.killNotificationSuppressed = true;
1133
+ delete meta.notificationPaused;
1057
1134
  writeMeta(id, meta);
1058
1135
  }
1059
1136
  }
1060
1137
 
1138
+ export function canRestoreNotificationAfterWait(meta: {
1139
+ notified?: boolean;
1140
+ killNotificationSuppressed?: boolean;
1141
+ }): boolean {
1142
+ return meta.notified === true && meta.killNotificationSuppressed !== true;
1143
+ }
1144
+
1061
1145
  function enableNotify(id: string): void {
1062
1146
  const meta = readMeta(id);
1063
- if (meta && meta.kind === "process" && meta.notified) {
1147
+ if (meta && meta.kind === "process" && canRestoreNotificationAfterWait(meta)) {
1064
1148
  meta.notified = false;
1065
1149
  writeMeta(id, meta);
1066
1150
  }
1067
1151
  }
1068
1152
 
1153
+ function pauseNotify(id: string): void {
1154
+ const meta = readMeta(id);
1155
+ if (meta && meta.kind === "process" && !meta.notificationPaused) {
1156
+ meta.notificationPaused = true;
1157
+ writeMeta(id, meta);
1158
+ }
1159
+ }
1160
+
1161
+ function resumeNotify(id: string): void {
1162
+ const meta = readMeta(id);
1163
+ if (meta && meta.kind === "process" && meta.notificationPaused) {
1164
+ delete meta.notificationPaused;
1165
+ writeMeta(id, meta);
1166
+ }
1167
+ }
1168
+
1069
1169
  // Wait for a PROCESS session: either until a regex appears in its output
1070
1170
  // (`expect` — e.g. "server listening") or until the process exits.
1071
1171
  async function waitForExit(
@@ -1139,7 +1239,7 @@ async function waitForExit(
1139
1239
  kind: "exited",
1140
1240
  ok,
1141
1241
  text:
1142
- `Process ${id}${meta?.command ? ` (${meta.command})` : ""} ` +
1242
+ `Process ${id}${meta?.command ? ` (${summarizeNotificationCommand(meta.command)})` : ""} ` +
1143
1243
  (workerDead
1144
1244
  ? "worker-dead: the babysit supervisor disappeared without an exit status"
1145
1245
  : ok ? "completed successfully" : `exited with code ${st.exit_code ?? "?"}`) +
@@ -1198,7 +1298,7 @@ export default function (pi: ExtensionAPI) {
1198
1298
  for (const s of sessions) {
1199
1299
  if (s.state === "running") continue;
1200
1300
  const meta = readMeta(s.id);
1201
- if (!meta || meta.kind !== "process" || meta.notified) continue;
1301
+ if (!meta || meta.kind !== "process" || meta.notified || meta.notificationPaused) continue;
1202
1302
  // Delay delivery by one poll interval. This gives an agent that chose
1203
1303
  // babysit_wait immediately after babysit_run enough time to claim the
1204
1304
  // completion and suppress the otherwise duplicate automatic message.
@@ -1208,15 +1308,13 @@ export default function (pi: ExtensionAPI) {
1208
1308
  continue;
1209
1309
  }
1210
1310
  if (Date.now() - meta.completionObservedAt < POLL_MS) continue;
1211
- meta.notified = true;
1212
- writeMeta(s.id, meta);
1213
1311
  const ok = s.exit_code === 0;
1214
1312
  const status: DisplayStatus = ok
1215
1313
  ? "success"
1216
1314
  : s.state === "dead" || s.exit_code == null
1217
1315
  ? "terminated"
1218
1316
  : "failed";
1219
- const output = await inlineOutput(s.id, s);
1317
+ const output = await inlineOutput(s.id, s, NOTIFY_OUTPUT_MAX_BYTES);
1220
1318
  const runtime = meta.startedAt
1221
1319
  ? `${Math.round((Date.now() - meta.startedAt) / 1000)}s`
1222
1320
  : "?";
@@ -1225,24 +1323,43 @@ export default function (pi: ExtensionAPI) {
1225
1323
  : s.state === "dead" || s.exit_code == null
1226
1324
  ? `Process "${s.id}" was terminated after ${runtime}.`
1227
1325
  : `Process "${s.id}" exited with code ${s.exit_code} after ${runtime}.`;
1228
- pi.sendMessage(
1229
- {
1230
- customType: "pi-babysit-process-end",
1231
- content:
1232
- `${summary}\nCommand: ${meta.command ?? "?"}\nLog: ${logPath(s.id)}${output}` +
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.`,
1234
- display: true,
1235
- details: {
1236
- id: s.id,
1237
- exitCode: s.exit_code,
1238
- success: ok,
1239
- status,
1240
- runtime,
1241
- logPath: logPath(s.id),
1326
+ // Output loading is asynchronous. A kill/wait can claim completion in
1327
+ // that window, so re-read metadata immediately before delivery.
1328
+ const current = readMeta(s.id);
1329
+ if (
1330
+ !current ||
1331
+ current.kind !== "process" ||
1332
+ current.notified ||
1333
+ current.notificationPaused
1334
+ ) {
1335
+ continue;
1336
+ }
1337
+ try {
1338
+ pi.sendMessage(
1339
+ {
1340
+ customType: "pi-babysit-process-end",
1341
+ content:
1342
+ `${summary}\nCommand: ${summarizeNotificationCommand(current.command)}\nLog: ${logPath(s.id)}${output}` +
1343
+ "\n\nAutomatic completion notification. Inspect the bounded log with babysit_check only if needed.",
1344
+ display: true,
1345
+ details: {
1346
+ id: s.id,
1347
+ exitCode: s.exit_code,
1348
+ success: ok,
1349
+ status,
1350
+ runtime,
1351
+ logPath: logPath(s.id),
1352
+ },
1242
1353
  },
1243
- },
1244
- { triggerTurn: true, deliverAs: "steer" },
1245
- );
1354
+ { triggerTurn: true, deliverAs: "steer" },
1355
+ );
1356
+ } catch {
1357
+ // Leave it pending so the next poll can retry delivery.
1358
+ continue;
1359
+ }
1360
+ current.notified = true;
1361
+ delete current.notificationPaused;
1362
+ writeMeta(s.id, current);
1246
1363
  }
1247
1364
  }
1248
1365
 
@@ -2181,17 +2298,46 @@ export default function (pi: ExtensionAPI) {
2181
2298
  parameters: Type.Object({ id: Type.String({ description: "Session id." }) }),
2182
2299
  async execute(_id, params, _signal, _onUpdate, ctx) {
2183
2300
  await requireBabysit();
2184
- suppressNotify(params.id); // tool-initiated kill → no end notification
2185
- const r = await bs(["kill", "-s", params.id, "--json"]);
2186
- await refreshWidget(ctx);
2187
- if (r.code !== 0) {
2301
+ // Prevent the exit poller racing a requested kill, but restore delivery
2302
+ // on every failure. Permanent suppression happens only after terminal
2303
+ // state is independently confirmed.
2304
+ pauseNotify(params.id);
2305
+ const fail = async (message: string, status?: BsSession | null) => {
2306
+ resumeNotify(params.id);
2307
+ await refreshWidget(ctx);
2188
2308
  return {
2189
- content: [{ type: "text", text: r.stderr || "kill failed" }],
2309
+ content: [{ type: "text" as const, text: message }],
2190
2310
  isError: true,
2191
- details: {},
2311
+ details: { id: params.id, status: status?.state, logPath: logPath(params.id) },
2192
2312
  };
2313
+ };
2314
+
2315
+ const r = await bs(["kill", "-s", params.id, "--json"]);
2316
+ if (r.code !== 0) return fail((r.stderr || r.stdout || "kill failed").trim());
2317
+
2318
+ const responseError = validateKillResponse(r.stdout);
2319
+ if (responseError) return fail(responseError);
2320
+
2321
+ const status = await awaitConfirmedTermination(params.id);
2322
+ if (!status) return fail(`Kill could not be verified: session ${params.id} disappeared.`);
2323
+ if (!isConfirmedTerminalState(status.state)) {
2324
+ return fail(
2325
+ `Kill was acknowledged but ${params.id} is still ${status.state}; completion notifications were restored.`,
2326
+ status,
2327
+ );
2193
2328
  }
2194
- return { content: [{ type: "text", text: `Killed ${params.id}.` }], details: {} };
2329
+
2330
+ suppressNotify(params.id, "kill");
2331
+ await refreshWidget(ctx);
2332
+ return {
2333
+ content: [{ type: "text", text: `Killed ${params.id} (confirmed ${status.state}).` }],
2334
+ details: {
2335
+ id: params.id,
2336
+ status: status.state,
2337
+ exitCode: status.exit_code,
2338
+ logPath: logPath(params.id),
2339
+ },
2340
+ };
2195
2341
  },
2196
2342
  });
2197
2343
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
5
5
  "keywords": [
6
6
  "pi-package",