@yusukeshib/pi-babysit 0.3.7 → 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 +14 -9
  2. package/index.ts +255 -54
  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
@@ -72,9 +72,12 @@ A minimal widget above the editor shows live counts
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
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:
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:
78
81
 
79
82
  ```text
80
83
  babysit_check { id: "cargo-test", lines: 50 }
@@ -100,9 +103,10 @@ because blindly rerunning an arbitrary command can duplicate side effects.
100
103
  ## How completion detection works
101
104
 
102
105
  - **Process**: a 2.5s poller watches for running→exited transitions and injects
103
- one `pi.sendMessage(…, { triggerTurn: true, deliverAs: "steer" })` per ended
104
- session (deduped via `meta/<id>.json`). `babysit_kill` and an exit already
105
- 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.
106
110
  - **Subagent**: `babysit_wait` blocks on `babysit expect '"type":"agent_end"'`.
107
111
  An `agent_end` whose last message is a **parked** toolResult — a
108
112
  `babysit_run { command }` result carrying the `[notify-on-exit]` marker (or
@@ -126,8 +130,9 @@ rule, so a subagent waiting on a long build is never false-killed.
126
130
  | `PI_BABYSIT_REAP_AFTER` | `120s` | idle grace before a finished subagent self-exits (`off`/`none`/`0` disables) |
127
131
  | `PI_BABYSIT_TAIL_MAX_BYTES` | `8000` | cap for explicit log tails/screens returned by `babysit_check` |
128
132
  | `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 |
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 |
131
136
  | `PI_BABYSIT_ALLOW_BASH` | unset | set to `1` to bypass direct-Bash redirection (emergency escape hatch) |
132
137
 
133
138
  Requires `babysit` 0.13.0 or newer and `pi` on `PATH`. The extension does **not**
package/index.ts CHANGED
@@ -317,6 +317,16 @@ function readMeta(id: string): Meta | null {
317
317
  }
318
318
  }
319
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
+
320
330
  const kindOf = (id: string): "process" | "subagent" => readMeta(id)?.kind ?? "process";
321
331
 
322
332
  // Compact elapsed formatting: "42s", "3m12s", "1h04m".
@@ -437,6 +447,7 @@ const TAIL_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_TAIL_MAX_BYTES", 8_000);
437
447
  const INLINE_OUTPUT_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_INLINE_OUTPUT_MAX_BYTES", 8_000);
438
448
  const NOTIFY_OUTPUT_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_OUTPUT_MAX_BYTES", 2_000);
439
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);
440
451
  const ANSWER_MAX_BYTES = 24_000; // subagent answers / error messages
441
452
 
442
453
  function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
@@ -560,6 +571,193 @@ export function summarizeNotificationCommand(command: string | undefined): strin
560
571
  return `${prefix}…`;
561
572
  }
562
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
+ }
563
761
 
564
762
  // ---------------------------------------------------------------------------
565
763
  // parked-turn detection (shared rule with self-reap.ts)
@@ -1289,78 +1487,81 @@ export default function (pi: ExtensionAPI) {
1289
1487
  let pollTimer: ReturnType<typeof setInterval> | undefined;
1290
1488
 
1291
1489
  // Exit notifications for kind=process sessions: the poller detects
1292
- // running→exited transitions and injects ONE message (triggerTurn) so an
1293
- // agent that ended its turn after babysit_run is resumed automatically —
1294
- // the old `process` tool contract. Kills via babysit_kill and exits already
1295
- // 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.
1296
1495
  async function notifyEndedProcesses(): Promise<void> {
1297
1496
  const { sessions } = await listSessions();
1298
- for (const s of sessions) {
1299
- if (s.state === "running") continue;
1300
- const meta = readMeta(s.id);
1301
- if (!meta || meta.kind !== "process" || meta.notified || meta.notificationPaused) 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;
1302
1502
  // Delay delivery by one poll interval. This gives an agent that chose
1303
1503
  // babysit_wait immediately after babysit_run enough time to claim the
1304
1504
  // completion and suppress the otherwise duplicate automatic message.
1305
1505
  if (!meta.completionObservedAt) {
1306
1506
  meta.completionObservedAt = Date.now();
1307
- writeMeta(s.id, meta);
1507
+ writeMeta(session.id, meta);
1308
1508
  continue;
1309
1509
  }
1310
1510
  if (Date.now() - meta.completionObservedAt < POLL_MS) continue;
1311
- const ok = s.exit_code === 0;
1312
- 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
1313
1518
  ? "success"
1314
- : s.state === "dead" || s.exit_code == null
1519
+ : session.state === "dead" || session.exit_code == null
1315
1520
  ? "terminated"
1316
1521
  : "failed";
1317
- const output = await inlineOutput(s.id, s, NOTIFY_OUTPUT_MAX_BYTES);
1522
+ const output = await inlineOutput(session.id, session, NOTIFY_OUTPUT_MAX_BYTES);
1318
1523
  const runtime = meta.startedAt
1319
1524
  ? `${Math.round((Date.now() - meta.startedAt) / 1000)}s`
1320
1525
  : "?";
1321
1526
  const summary = ok
1322
- ? `Process "${s.id}" completed successfully after ${runtime}.`
1323
- : s.state === "dead" || s.exit_code == null
1324
- ? `Process "${s.id}" was terminated after ${runtime}.`
1325
- : `Process "${s.id}" exited with code ${s.exit_code} after ${runtime}.`;
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
- },
1353
- },
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);
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
+ });
1363
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
+ );
1364
1565
  }
1365
1566
 
1366
1567
  const refreshWidget = async (ctx: ExtensionContext) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.3.7",
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",