@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.
- package/README.md +9 -4
- package/index.ts +185 -39
- 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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
another shell
|
|
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
|
-
|
|
393
|
-
const
|
|
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
|
-
|
|
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
|
|
531
|
+
if (!shouldInlineCompleteOutput(bytes, maxBytes)) {
|
|
478
532
|
const size = Number.isFinite(bytes) ? `${bytes} bytes` : "size unavailable";
|
|
479
|
-
return `\nOutput omitted (${size}; inline limit ${
|
|
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) >
|
|
483
|
-
return `\nOutput omitted (exceeds inline limit ${
|
|
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"
|
|
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
|
|
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
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|