@yusukeshib/pi-babysit 0.3.6 → 0.3.8

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 +18 -8
  2. package/index.ts +402 -55
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -32,7 +32,7 @@ reachable from anywhere (`~/.pi-babysit/<pi-session-id>/`). Two kinds:
32
32
 
33
33
  | kind | started by | completion | on completion |
34
34
  | ---- | ---------- | ---------- | ------------- |
35
- | **process** | `babysit_run { command }` | process **exit** | automatic notification message (`triggerTurn`) — the agent may end its turn after starting and is resumed on exit, same contract as the old `process` tool |
35
+ | **process** | `babysit_run { command }` | process **exit** | automatic notification message (`triggerTurn`), batched for all exits observed in the same poll — the agent may end its turn after starting and is resumed on exit, same contract as the old `process` tool |
36
36
  | **subagent** | `babysit_run { profile: "subagent", task }` | `agent_end` in the RPC event stream (process stays alive) | none — the agent polls `babysit_check` or blocks on `babysit_wait`; the idle session accepts follow-up tasks |
37
37
 
38
38
  The **profile is a tool parameter, not a separate tool set**: domain knowledge
@@ -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,13 @@ 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 per-process output cap and an 8 KB
76
+ aggregate message cap. Process exits observed in one poll share one message and
77
+ trigger one agent turn. If even the compact summaries and log paths exceed the
78
+ aggregate cap, the largest fitting prefix is delivered and the remainder stays
79
+ pending for the next poll. Larger output stays out of model context. Inspect it
80
+ through the session id without creating another shell session:
77
81
 
78
82
  ```text
79
83
  babysit_check { id: "cargo-test", lines: 50 }
@@ -99,9 +103,10 @@ because blindly rerunning an arbitrary command can duplicate side effects.
99
103
  ## How completion detection works
100
104
 
101
105
  - **Process**: a 2.5s poller watches for running→exited transitions and injects
102
- one `pi.sendMessage(…, { triggerTurn: true, deliverAs: "steer" })` per ended
103
- session (deduped via `meta/<id>.json`). `babysit_kill` and an exit already
104
- reported by `babysit_wait` suppress the notification.
106
+ one `pi.sendMessage(…, { triggerTurn: true, deliverAs: "steer" })` containing
107
+ every deliverable exit observed in that poll (deduped via `meta/<id>.json`).
108
+ `babysit_kill` and an exit already reported by `babysit_wait` suppress the
109
+ notification.
105
110
  - **Subagent**: `babysit_wait` blocks on `babysit expect '"type":"agent_end"'`.
106
111
  An `agent_end` whose last message is a **parked** toolResult — a
107
112
  `babysit_run { command }` result carrying the `[notify-on-exit]` marker (or
@@ -123,6 +128,11 @@ rule, so a subagent waiting on a long build is never false-killed.
123
128
  | `PI_BABYSIT_CLI` | `babysit` | babysit binary |
124
129
  | `PI_BABYSIT_VIEW_CMD` | bundled `format-stream.mjs` | live-attach pretty printer for subagent JSONL (`""` disables) |
125
130
  | `PI_BABYSIT_REAP_AFTER` | `120s` | idle grace before a finished subagent self-exits (`off`/`none`/`0` disables) |
131
+ | `PI_BABYSIT_TAIL_MAX_BYTES` | `8000` | cap for explicit log tails/screens returned by `babysit_check` |
132
+ | `PI_BABYSIT_INLINE_OUTPUT_MAX_BYTES` | `8000` | cap for complete output in explicitly requested run/wait results |
133
+ | `PI_BABYSIT_NOTIFY_OUTPUT_MAX_BYTES` | `2000` | per-process output cap for unsolicited completion notifications (`0` omits all output) |
134
+ | `PI_BABYSIT_NOTIFY_COMMAND_MAX_BYTES` | `240` | cap for each command preview in completion notifications |
135
+ | `PI_BABYSIT_NOTIFY_BATCH_MAX_BYTES` | `8000` | hard cap for one aggregated completion notification |
126
136
  | `PI_BABYSIT_ALLOW_BASH` | unset | set to `1` to bypass direct-Bash redirection (emergency escape hatch) |
127
137
 
128
138
  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
@@ -282,6 +317,16 @@ function readMeta(id: string): Meta | null {
282
317
  }
283
318
  }
284
319
 
320
+ export function shouldDeliverProcessCompletion(
321
+ meta: {
322
+ kind: "process" | "subagent";
323
+ notified?: boolean;
324
+ notificationPaused?: boolean;
325
+ } | null,
326
+ ): meta is { kind: "process"; notified?: boolean; notificationPaused?: boolean } {
327
+ return meta?.kind === "process" && !meta.notified && !meta.notificationPaused;
328
+ }
329
+
285
330
  const kindOf = (id: string): "process" | "subagent" => readMeta(id)?.kind ?? "process";
286
331
 
287
332
  // Compact elapsed formatting: "42s", "3m12s", "1h04m".
@@ -389,8 +434,20 @@ function parseDurMs(s?: string): number | null {
389
434
  // megabytes, so we also cap bytes, eliding the middle so both the head and
390
435
  // the tail of the output stay visible.
391
436
 
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
437
+ function byteLimitFromEnv(name: string, fallback: number): number {
438
+ const raw = process.env[name];
439
+ if (raw == null || raw.trim() === "") return fallback;
440
+ const value = Number(raw);
441
+ return Number.isSafeInteger(value) && value >= 0 ? value : fallback;
442
+ }
443
+
444
+ const TAIL_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_TAIL_MAX_BYTES", 8_000);
445
+ // Direct run/wait results can carry more context because the caller explicitly
446
+ // requested them. Unsolicited completion notifications default much smaller.
447
+ const INLINE_OUTPUT_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_INLINE_OUTPUT_MAX_BYTES", 8_000);
448
+ const NOTIFY_OUTPUT_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_OUTPUT_MAX_BYTES", 2_000);
449
+ const NOTIFY_COMMAND_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_COMMAND_MAX_BYTES", 240);
450
+ const NOTIFY_BATCH_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_BATCH_MAX_BYTES", 8_000);
394
451
  const ANSWER_MAX_BYTES = 24_000; // subagent answers / error messages
395
452
 
396
453
  function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
@@ -465,7 +522,15 @@ async function searchLog(
465
522
  });
466
523
  }
467
524
 
468
- async function inlineOutput(id: string, status: BsSession): Promise<string> {
525
+ export function shouldInlineCompleteOutput(outputBytes: number, maxBytes: number): boolean {
526
+ return maxBytes > 0 && outputBytes <= maxBytes;
527
+ }
528
+
529
+ async function inlineOutput(
530
+ id: string,
531
+ status: BsSession,
532
+ maxBytes = INLINE_OUTPUT_MAX_BYTES,
533
+ ): Promise<string> {
469
534
  let bytes = status.output_bytes;
470
535
  if (bytes == null) {
471
536
  try {
@@ -474,17 +539,225 @@ async function inlineOutput(id: string, status: BsSession): Promise<string> {
474
539
  bytes = Number.POSITIVE_INFINITY;
475
540
  }
476
541
  }
477
- if (bytes > INLINE_OUTPUT_MAX_BYTES) {
542
+ if (!shouldInlineCompleteOutput(bytes, maxBytes)) {
478
543
  const size = Number.isFinite(bytes) ? `${bytes} bytes` : "size unavailable";
479
- return `\nOutput omitted (${size}; inline limit ${INLINE_OUTPUT_MAX_BYTES}).`;
544
+ return `\nOutput omitted (${size}; inline limit ${maxBytes}).`;
480
545
  }
481
546
  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).`;
547
+ if (Buffer.byteLength(output) > maxBytes) {
548
+ return `\nOutput omitted (exceeds inline limit ${maxBytes} bytes).`;
484
549
  }
485
550
  return output ? `\n\nOutput:\n${output}` : "";
486
551
  }
487
552
 
553
+ export function summarizeNotificationCommand(command: string | undefined): string {
554
+ const preview =
555
+ (command ?? "?")
556
+ .trim()
557
+ .replace(/\r/g, "\\r")
558
+ .replace(/\n/g, "\\n")
559
+ .replace(/\t/g, "\\t") || "?";
560
+ const bytes = Buffer.from(preview, "utf8");
561
+ if (bytes.length <= NOTIFY_COMMAND_MAX_BYTES) return preview;
562
+ if (NOTIFY_COMMAND_MAX_BYTES === 0) return "";
563
+ const ellipsis = Buffer.from("…", "utf8");
564
+ if (NOTIFY_COMMAND_MAX_BYTES <= ellipsis.length) {
565
+ return ".".repeat(NOTIFY_COMMAND_MAX_BYTES);
566
+ }
567
+ const prefix = bytes
568
+ .subarray(0, NOTIFY_COMMAND_MAX_BYTES - ellipsis.length)
569
+ .toString("utf8")
570
+ .replace(/\uFFFD+$/, "");
571
+ return `${prefix}…`;
572
+ }
573
+
574
+ type CompletionStatus = "success" | "failed" | "terminated";
575
+
576
+ export interface ProcessCompletionNotice {
577
+ id: string;
578
+ exitCode: number | null | undefined;
579
+ success: boolean;
580
+ status: CompletionStatus;
581
+ runtime: string;
582
+ summary: string;
583
+ command: string | undefined;
584
+ logPath: string;
585
+ output: string;
586
+ }
587
+
588
+ interface ProcessCompletionMessage {
589
+ customType: "pi-babysit-process-end";
590
+ content: string;
591
+ display: true;
592
+ details: {
593
+ id?: string;
594
+ exitCode?: number | null;
595
+ success: boolean;
596
+ status: CompletionStatus;
597
+ runtime?: string;
598
+ logPath?: string;
599
+ count: number;
600
+ totalCount: number;
601
+ remainingCount: number;
602
+ processes: Array<{
603
+ id: string;
604
+ exitCode: number | null | undefined;
605
+ success: boolean;
606
+ status: CompletionStatus;
607
+ runtime: string;
608
+ logPath: string;
609
+ }>;
610
+ };
611
+ }
612
+
613
+ const COMPLETION_FOOTER =
614
+ "Automatic completion notification. Inspect the bounded log with babysit_check only if needed.";
615
+ const AGGREGATE_OUTPUT_OMISSION = "\nOutput omitted from aggregate notification; inspect log.";
616
+
617
+ function truncateUtf8End(value: string, maxBytes: number): string {
618
+ if (maxBytes <= 0) return "";
619
+ const bytes = Buffer.from(value, "utf8");
620
+ if (bytes.length <= maxBytes) return value;
621
+ const suffix = Buffer.from("…", "utf8");
622
+ if (maxBytes <= suffix.length) return ".".repeat(maxBytes);
623
+ const prefix = bytes
624
+ .subarray(0, maxBytes - suffix.length)
625
+ .toString("utf8")
626
+ .replace(/\uFFFD+$/, "");
627
+ return `${prefix}…`;
628
+ }
629
+
630
+ /** Build one bounded message for as many deliverable completions as fit. */
631
+ export function buildProcessCompletionMessage(
632
+ notices: ProcessCompletionNotice[],
633
+ maxBytes = NOTIFY_BATCH_MAX_BYTES,
634
+ ): ProcessCompletionMessage {
635
+ if (notices.length === 0) throw new Error("At least one completion notice is required.");
636
+
637
+ const totalCount = notices.length;
638
+ const footer = `\n\n${COMPLETION_FOOTER}`;
639
+ const header = (count: number) =>
640
+ totalCount === 1
641
+ ? ""
642
+ : count === totalCount
643
+ ? `${count} processes completed:\n\n`
644
+ : `${count} of ${totalCount} processes completed:\n\n`;
645
+ const deferred = (count: number) =>
646
+ count < totalCount
647
+ ? `\n\n${totalCount - count} completion${totalCount - count === 1 ? "" : "s"} deferred to the next poll.`
648
+ : "";
649
+ const renderCompact = (batch: ProcessCompletionNotice[]) =>
650
+ header(batch.length) +
651
+ batch.map((notice) => `${notice.summary}\nLog: ${notice.logPath}`).join("\n\n") +
652
+ deferred(batch.length) +
653
+ footer;
654
+
655
+ // If every summary and log path cannot fit, notify the largest fitting prefix
656
+ // and leave the rest unacknowledged for a later poll. Always make progress for
657
+ // pathological single ids/paths by delivering one UTF-8-truncated entry.
658
+ let batch = notices;
659
+ if (Buffer.byteLength(renderCompact(batch), "utf8") > maxBytes) {
660
+ let low = 1;
661
+ let high = notices.length - 1;
662
+ let fittedCount = 0;
663
+ while (low <= high) {
664
+ const mid = Math.floor((low + high) / 2);
665
+ if (Buffer.byteLength(renderCompact(notices.slice(0, mid)), "utf8") <= maxBytes) {
666
+ fittedCount = mid;
667
+ low = mid + 1;
668
+ } else {
669
+ high = mid - 1;
670
+ }
671
+ }
672
+ batch = fittedCount > 0 ? notices.slice(0, fittedCount) : [notices[0]];
673
+ }
674
+
675
+ const blockBase = (notice: ProcessCompletionNotice) =>
676
+ `${notice.summary}\nCommand: ${summarizeNotificationCommand(notice.command)}\nLog: ${notice.logPath}`;
677
+ const outputs = batch.map((notice) =>
678
+ notice.output.startsWith("\n\nOutput:") ? AGGREGATE_OUTPUT_OMISSION : notice.output,
679
+ );
680
+ const renderDetailed = () =>
681
+ header(batch.length) +
682
+ batch.map((notice, i) => blockBase(notice) + outputs[i]).join("\n\n") +
683
+ deferred(batch.length) +
684
+ footer;
685
+
686
+ let content = renderDetailed();
687
+ let contentBytes = Buffer.byteLength(content, "utf8");
688
+ if (contentBytes <= maxBytes) {
689
+ // Spend the remaining aggregate budget on complete inline outputs without
690
+ // repeatedly rebuilding the entire batch for every candidate.
691
+ for (let i = 0; i < batch.length; i++) {
692
+ if (!batch[i].output.startsWith("\n\nOutput:")) continue;
693
+ const delta =
694
+ Buffer.byteLength(batch[i].output, "utf8") - Buffer.byteLength(outputs[i], "utf8");
695
+ if (contentBytes + delta > maxBytes) continue;
696
+ outputs[i] = batch[i].output;
697
+ contentBytes += delta;
698
+ }
699
+ content = renderDetailed();
700
+ } else {
701
+ content = truncateUtf8End(renderCompact(batch), maxBytes);
702
+ }
703
+
704
+ const statuses = new Set(batch.map((notice) => notice.status));
705
+ const status: CompletionStatus = statuses.has("failed")
706
+ ? "failed"
707
+ : statuses.has("terminated")
708
+ ? "terminated"
709
+ : "success";
710
+ const processes = batch.map((notice) => ({
711
+ id: notice.id,
712
+ exitCode: notice.exitCode,
713
+ success: notice.success,
714
+ status: notice.status,
715
+ runtime: notice.runtime,
716
+ logPath: notice.logPath,
717
+ }));
718
+ const single = totalCount === 1 ? batch[0] : undefined;
719
+ return {
720
+ customType: "pi-babysit-process-end",
721
+ content,
722
+ display: true,
723
+ details: {
724
+ id: single?.id,
725
+ exitCode: single?.exitCode,
726
+ success: batch.every((notice) => notice.success),
727
+ status,
728
+ runtime: single?.runtime,
729
+ logPath: single?.logPath,
730
+ count: batch.length,
731
+ totalCount,
732
+ remainingCount: totalCount - batch.length,
733
+ processes,
734
+ },
735
+ };
736
+ }
737
+
738
+ /** Send once and only acknowledge notices represented in the accepted batch. */
739
+ export function deliverProcessCompletionMessage(
740
+ notices: ProcessCompletionNotice[],
741
+ send: (
742
+ message: ProcessCompletionMessage,
743
+ options: { triggerTurn: true; deliverAs: "steer" },
744
+ ) => void,
745
+ onSent: (notice: ProcessCompletionNotice) => void,
746
+ ): boolean {
747
+ if (notices.length === 0) return false;
748
+ let message: ProcessCompletionMessage;
749
+ try {
750
+ message = buildProcessCompletionMessage(notices);
751
+ send(message, { triggerTurn: true, deliverAs: "steer" });
752
+ } catch {
753
+ return false;
754
+ }
755
+ const deliveredIds = new Set(message.details.processes.map(({ id }) => id));
756
+ for (const notice of notices) {
757
+ if (deliveredIds.has(notice.id)) onSent(notice);
758
+ }
759
+ return true;
760
+ }
488
761
 
489
762
  // ---------------------------------------------------------------------------
490
763
  // parked-turn detection (shared rule with self-reap.ts)
@@ -1050,22 +1323,47 @@ async function waitForTask(
1050
1323
 
1051
1324
  // Mark a process session as already-reported so the exit-notification poller
1052
1325
  // doesn't send a duplicate message for something the agent just observed.
1053
- function suppressNotify(id: string): void {
1326
+ function suppressNotify(id: string, reason: "observed" | "kill" = "observed"): void {
1054
1327
  const meta = readMeta(id);
1055
- if (meta && meta.kind === "process" && !meta.notified) {
1328
+ if (meta && meta.kind === "process") {
1056
1329
  meta.notified = true;
1330
+ if (reason === "kill") meta.killNotificationSuppressed = true;
1331
+ delete meta.notificationPaused;
1057
1332
  writeMeta(id, meta);
1058
1333
  }
1059
1334
  }
1060
1335
 
1336
+ export function canRestoreNotificationAfterWait(meta: {
1337
+ notified?: boolean;
1338
+ killNotificationSuppressed?: boolean;
1339
+ }): boolean {
1340
+ return meta.notified === true && meta.killNotificationSuppressed !== true;
1341
+ }
1342
+
1061
1343
  function enableNotify(id: string): void {
1062
1344
  const meta = readMeta(id);
1063
- if (meta && meta.kind === "process" && meta.notified) {
1345
+ if (meta && meta.kind === "process" && canRestoreNotificationAfterWait(meta)) {
1064
1346
  meta.notified = false;
1065
1347
  writeMeta(id, meta);
1066
1348
  }
1067
1349
  }
1068
1350
 
1351
+ function pauseNotify(id: string): void {
1352
+ const meta = readMeta(id);
1353
+ if (meta && meta.kind === "process" && !meta.notificationPaused) {
1354
+ meta.notificationPaused = true;
1355
+ writeMeta(id, meta);
1356
+ }
1357
+ }
1358
+
1359
+ function resumeNotify(id: string): void {
1360
+ const meta = readMeta(id);
1361
+ if (meta && meta.kind === "process" && meta.notificationPaused) {
1362
+ delete meta.notificationPaused;
1363
+ writeMeta(id, meta);
1364
+ }
1365
+ }
1366
+
1069
1367
  // Wait for a PROCESS session: either until a regex appears in its output
1070
1368
  // (`expect` — e.g. "server listening") or until the process exits.
1071
1369
  async function waitForExit(
@@ -1139,7 +1437,7 @@ async function waitForExit(
1139
1437
  kind: "exited",
1140
1438
  ok,
1141
1439
  text:
1142
- `Process ${id}${meta?.command ? ` (${meta.command})` : ""} ` +
1440
+ `Process ${id}${meta?.command ? ` (${summarizeNotificationCommand(meta.command)})` : ""} ` +
1143
1441
  (workerDead
1144
1442
  ? "worker-dead: the babysit supervisor disappeared without an exit status"
1145
1443
  : ok ? "completed successfully" : `exited with code ${st.exit_code ?? "?"}`) +
@@ -1189,61 +1487,81 @@ export default function (pi: ExtensionAPI) {
1189
1487
  let pollTimer: ReturnType<typeof setInterval> | undefined;
1190
1488
 
1191
1489
  // Exit notifications for kind=process sessions: the poller detects
1192
- // running→exited transitions and injects ONE message (triggerTurn) so an
1193
- // agent that ended its turn after babysit_run is resumed automatically —
1194
- // the old `process` tool contract. Kills via babysit_kill and exits already
1195
- // reported by babysit_wait are suppressed via meta.notified.
1490
+ // running→exited transitions and injects ONE message (triggerTurn) for all
1491
+ // processes that became deliverable in the same poll. This resumes an agent
1492
+ // that ended its turn after babysit_run without spending one turn per exit.
1493
+ // Kills via babysit_kill and exits already reported by babysit_wait are
1494
+ // suppressed via meta.notified.
1196
1495
  async function notifyEndedProcesses(): Promise<void> {
1197
1496
  const { sessions } = await listSessions();
1198
- for (const s of sessions) {
1199
- if (s.state === "running") continue;
1200
- const meta = readMeta(s.id);
1201
- if (!meta || meta.kind !== "process" || meta.notified) continue;
1497
+ const ready: Array<{ session: BsSession; meta: Meta }> = [];
1498
+ for (const session of sessions) {
1499
+ if (session.state === "running") continue;
1500
+ const meta = readMeta(session.id);
1501
+ if (!shouldDeliverProcessCompletion(meta)) continue;
1202
1502
  // Delay delivery by one poll interval. This gives an agent that chose
1203
1503
  // babysit_wait immediately after babysit_run enough time to claim the
1204
1504
  // completion and suppress the otherwise duplicate automatic message.
1205
1505
  if (!meta.completionObservedAt) {
1206
1506
  meta.completionObservedAt = Date.now();
1207
- writeMeta(s.id, meta);
1507
+ writeMeta(session.id, meta);
1208
1508
  continue;
1209
1509
  }
1210
1510
  if (Date.now() - meta.completionObservedAt < POLL_MS) continue;
1211
- meta.notified = true;
1212
- writeMeta(s.id, meta);
1213
- const ok = s.exit_code === 0;
1214
- const status: DisplayStatus = ok
1511
+ ready.push({ session, meta });
1512
+ }
1513
+
1514
+ const prepared: ProcessCompletionNotice[] = [];
1515
+ for (const { session, meta } of ready) {
1516
+ const ok = session.exit_code === 0;
1517
+ const status: CompletionStatus = ok
1215
1518
  ? "success"
1216
- : s.state === "dead" || s.exit_code == null
1519
+ : session.state === "dead" || session.exit_code == null
1217
1520
  ? "terminated"
1218
1521
  : "failed";
1219
- const output = await inlineOutput(s.id, s);
1522
+ const output = await inlineOutput(session.id, session, NOTIFY_OUTPUT_MAX_BYTES);
1220
1523
  const runtime = meta.startedAt
1221
1524
  ? `${Math.round((Date.now() - meta.startedAt) / 1000)}s`
1222
1525
  : "?";
1223
1526
  const summary = ok
1224
- ? `Process "${s.id}" completed successfully after ${runtime}.`
1225
- : s.state === "dead" || s.exit_code == null
1226
- ? `Process "${s.id}" was terminated after ${runtime}.`
1227
- : `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),
1242
- },
1243
- },
1244
- { triggerTurn: true, deliverAs: "steer" },
1245
- );
1527
+ ? `Process "${session.id}" completed successfully after ${runtime}.`
1528
+ : session.state === "dead" || session.exit_code == null
1529
+ ? `Process "${session.id}" was terminated after ${runtime}.`
1530
+ : `Process "${session.id}" exited with code ${session.exit_code} after ${runtime}.`;
1531
+ prepared.push({
1532
+ id: session.id,
1533
+ exitCode: session.exit_code,
1534
+ success: ok,
1535
+ status,
1536
+ runtime,
1537
+ summary,
1538
+ command: meta.command,
1539
+ logPath: logPath(session.id),
1540
+ output,
1541
+ });
1246
1542
  }
1543
+
1544
+ // Output loading above is asynchronous. Re-read every candidate together
1545
+ // immediately before the single send so a concurrent kill/wait cannot claim
1546
+ // an early candidate while a later candidate's output is being loaded.
1547
+ const metadataById = new Map<string, Meta>();
1548
+ const notices = prepared.flatMap((notice) => {
1549
+ const current = readMeta(notice.id);
1550
+ if (!shouldDeliverProcessCompletion(current)) return [];
1551
+ metadataById.set(notice.id, current);
1552
+ return [{ ...notice, command: current.command }];
1553
+ });
1554
+ deliverProcessCompletionMessage(
1555
+ notices,
1556
+ (message, options) => pi.sendMessage(message, options),
1557
+ (notice) => {
1558
+ const meta = metadataById.get(notice.id);
1559
+ if (!meta) return;
1560
+ meta.notified = true;
1561
+ delete meta.notificationPaused;
1562
+ writeMeta(notice.id, meta);
1563
+ },
1564
+ );
1247
1565
  }
1248
1566
 
1249
1567
  const refreshWidget = async (ctx: ExtensionContext) => {
@@ -2181,17 +2499,46 @@ export default function (pi: ExtensionAPI) {
2181
2499
  parameters: Type.Object({ id: Type.String({ description: "Session id." }) }),
2182
2500
  async execute(_id, params, _signal, _onUpdate, ctx) {
2183
2501
  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) {
2502
+ // Prevent the exit poller racing a requested kill, but restore delivery
2503
+ // on every failure. Permanent suppression happens only after terminal
2504
+ // state is independently confirmed.
2505
+ pauseNotify(params.id);
2506
+ const fail = async (message: string, status?: BsSession | null) => {
2507
+ resumeNotify(params.id);
2508
+ await refreshWidget(ctx);
2188
2509
  return {
2189
- content: [{ type: "text", text: r.stderr || "kill failed" }],
2510
+ content: [{ type: "text" as const, text: message }],
2190
2511
  isError: true,
2191
- details: {},
2512
+ details: { id: params.id, status: status?.state, logPath: logPath(params.id) },
2192
2513
  };
2514
+ };
2515
+
2516
+ const r = await bs(["kill", "-s", params.id, "--json"]);
2517
+ if (r.code !== 0) return fail((r.stderr || r.stdout || "kill failed").trim());
2518
+
2519
+ const responseError = validateKillResponse(r.stdout);
2520
+ if (responseError) return fail(responseError);
2521
+
2522
+ const status = await awaitConfirmedTermination(params.id);
2523
+ if (!status) return fail(`Kill could not be verified: session ${params.id} disappeared.`);
2524
+ if (!isConfirmedTerminalState(status.state)) {
2525
+ return fail(
2526
+ `Kill was acknowledged but ${params.id} is still ${status.state}; completion notifications were restored.`,
2527
+ status,
2528
+ );
2193
2529
  }
2194
- return { content: [{ type: "text", text: `Killed ${params.id}.` }], details: {} };
2530
+
2531
+ suppressNotify(params.id, "kill");
2532
+ await refreshWidget(ctx);
2533
+ return {
2534
+ content: [{ type: "text", text: `Killed ${params.id} (confirmed ${status.state}).` }],
2535
+ details: {
2536
+ id: params.id,
2537
+ status: status.state,
2538
+ exitCode: status.exit_code,
2539
+ logPath: logPath(params.id),
2540
+ },
2541
+ };
2195
2542
  },
2196
2543
  });
2197
2544
 
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.8",
4
4
  "description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
5
5
  "keywords": [
6
6
  "pi-package",