@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.
- package/README.md +18 -8
- package/index.ts +402 -55
- 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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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" })`
|
|
103
|
-
|
|
104
|
-
reported by `babysit_wait` suppress the
|
|
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
|
-
|
|
393
|
-
const
|
|
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
|
-
|
|
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
|
|
542
|
+
if (!shouldInlineCompleteOutput(bytes, maxBytes)) {
|
|
478
543
|
const size = Number.isFinite(bytes) ? `${bytes} bytes` : "size unavailable";
|
|
479
|
-
return `\nOutput omitted (${size}; inline limit ${
|
|
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) >
|
|
483
|
-
return `\nOutput omitted (exceeds inline limit ${
|
|
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"
|
|
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
|
|
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)
|
|
1193
|
-
//
|
|
1194
|
-
//
|
|
1195
|
-
// reported by babysit_wait are
|
|
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
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
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(
|
|
1507
|
+
writeMeta(session.id, meta);
|
|
1208
1508
|
continue;
|
|
1209
1509
|
}
|
|
1210
1510
|
if (Date.now() - meta.completionObservedAt < POLL_MS) continue;
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
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
|
-
:
|
|
1519
|
+
: session.state === "dead" || session.exit_code == null
|
|
1217
1520
|
? "terminated"
|
|
1218
1521
|
: "failed";
|
|
1219
|
-
const output = await inlineOutput(
|
|
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 "${
|
|
1225
|
-
:
|
|
1226
|
-
? `Process "${
|
|
1227
|
-
: `Process "${
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|