@yusukeshib/pi-babysit 0.2.4 → 0.3.1
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 +27 -36
- package/index.ts +127 -252
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# pi-babysit
|
|
2
2
|
|
|
3
|
-
A [pi](https://github.com/earendil-works/pi) extension that runs **
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
A [pi](https://github.com/earendil-works/pi) extension that runs **any shell
|
|
4
|
+
command** under [babysit](https://github.com/yusukeshib/babysit)-supervised
|
|
5
|
+
sessions — one context-safe substrate for quick commands, background processes,
|
|
6
|
+
and pi subagents.
|
|
6
7
|
It retires both `@mjakl/pi-processes` (the `process` tool) and the old
|
|
7
8
|
`pi-subagent` extension.
|
|
8
9
|
|
|
@@ -48,9 +49,8 @@ programs** (installers, wizards, REPLs): type with `babysit_send`
|
|
|
48
49
|
|
|
49
50
|
| Tool | What it does |
|
|
50
51
|
| ---- | ------------ |
|
|
51
|
-
| `babysit_run` |
|
|
52
|
+
| `babysit_run` | Run any command (`command`, optional `name`/`pty`/`timeout`/`idleTimeout`/`retryOnWorkerDeath`) or start a subagent (`profile: "subagent"`, `task`, optional `agent`/`model`/`tools`). Quick commands return inline; longer ones continue in the background |
|
|
52
53
|
| `babysit_check` | List all sessions, or inspect one: process → state + log tail (or `screen: true` for TUIs); subagent → live progress (turns, recent tool calls, partial answer) |
|
|
53
|
-
| `babysit_analyze` | Run a local JavaScript, Python, or shell analyzer over a process's complete captured log; only its bounded report returns to the model |
|
|
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
56
|
| `babysit_kill` | Terminate a session (suppresses the exit notification) |
|
|
@@ -68,39 +68,30 @@ A `tool_call` hook also blocks bash commands that background themselves
|
|
|
68
68
|
A minimal widget above the editor shows live counts
|
|
69
69
|
(`N processes · M subagents working · K idle`).
|
|
70
70
|
|
|
71
|
-
##
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
name: "test",
|
|
83
|
-
command: "npm test",
|
|
84
|
-
report: {
|
|
85
|
-
language: "javascript",
|
|
86
|
-
code: `
|
|
87
|
-
const failures = FILE_CONTENT.split("\\n")
|
|
88
|
-
.filter(line => /FAIL|Error:|✗/.test(line));
|
|
89
|
-
console.log(`failures: ${failures.length}`);
|
|
90
|
-
console.log(failures.slice(-20).join("\\n"));
|
|
91
|
-
`,
|
|
92
|
-
},
|
|
93
|
-
});
|
|
71
|
+
## Logs without context flooding
|
|
72
|
+
|
|
73
|
+
`babysit_run`, `babysit_wait`, and automatic completion notifications always
|
|
74
|
+
return lifecycle metadata and the absolute path to the complete `output.log`.
|
|
75
|
+
When the complete output is at most 8 KB it is returned inline; larger output
|
|
76
|
+
stays out of model context. Inspect large logs on demand with bounded shell
|
|
77
|
+
commands such as:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
tail -n 50 /path/to/output.log
|
|
81
|
+
rg -n 'FAIL|ERROR' /path/to/output.log
|
|
94
82
|
```
|
|
95
83
|
|
|
96
|
-
|
|
97
|
-
read
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
84
|
+
`babysit_check { id, lines }` remains available as a convenient bounded tail.
|
|
85
|
+
Do not read a potentially large log file in full.
|
|
86
|
+
|
|
87
|
+
## External worker death
|
|
88
|
+
|
|
89
|
+
If endpoint security or another external actor kills the babysit supervisor,
|
|
90
|
+
pi-babysit normalizes the stale `running` state to `worker-dead`, returns
|
|
91
|
+
immediately instead of hanging, and explains that the command may have started.
|
|
92
|
+
For commands known to be safe and idempotent, set `retryOnWorkerDeath: true` to
|
|
93
|
+
retry once with a new session id. It is opt-in because blindly rerunning an
|
|
94
|
+
arbitrary command can duplicate side effects.
|
|
104
95
|
|
|
105
96
|
## How completion detection works
|
|
106
97
|
|
package/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi-babysit: run
|
|
3
|
-
* for background processes AND pi subagents. Retires both `pi-processes`
|
|
2
|
+
* pi-babysit: run ANY shell command under babysit — one context-safe supervision
|
|
3
|
+
* substrate for quick commands, background processes, AND pi subagents. Retires both `pi-processes`
|
|
4
4
|
* (the `process` tool) and `pi-subagent`.
|
|
5
5
|
*
|
|
6
6
|
* Every session is a babysit-supervised PTY (state in $PI_BABYSIT_DIR,
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* surface (babysit_run/check/send/wait/kill) covers both, and domain knowledge
|
|
27
27
|
* (RPC bookkeeping, byte offsets, parked-turn detection) stays in code.
|
|
28
28
|
*
|
|
29
|
-
* Tools (LLM): babysit_run, babysit_check,
|
|
29
|
+
* Tools (LLM): babysit_run, babysit_check, babysit_send, babysit_wait, babysit_kill
|
|
30
30
|
* Commands: /babysit (arrow-key picker: attach/tail/inspect)
|
|
31
31
|
* Widget: live counts (processes running · subagents working · idle)
|
|
32
32
|
*/
|
|
@@ -80,6 +80,7 @@ const SUBAGENT_GUIDANCE = [
|
|
|
80
80
|
"your controller reads it from the event stream.",
|
|
81
81
|
].join(" ");
|
|
82
82
|
const POLL_MS = 2500;
|
|
83
|
+
const QUICK_COMMAND_GRACE = process.env.PI_BABYSIT_QUICK_GRACE ?? "1s";
|
|
83
84
|
|
|
84
85
|
interface BsSession {
|
|
85
86
|
id: string;
|
|
@@ -225,21 +226,13 @@ async function statusOf(id: string): Promise<BsSession | null> {
|
|
|
225
226
|
// kind=process: name/command + `notified` (exit notification dedup).
|
|
226
227
|
// kind=subagent: task + the raw-log byte offset of the last prompt, which lets
|
|
227
228
|
// check/wait analyze only the CURRENT task's events (important for follow-ups).
|
|
228
|
-
type ReportLanguage = "javascript" | "python" | "shell";
|
|
229
|
-
|
|
230
|
-
interface ReportSpec {
|
|
231
|
-
language: ReportLanguage;
|
|
232
|
-
code: string;
|
|
233
|
-
timeout?: string;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
229
|
interface Meta {
|
|
237
230
|
kind: "process" | "subagent";
|
|
238
231
|
// process
|
|
239
232
|
name?: string;
|
|
240
233
|
command?: string;
|
|
241
|
-
report?: ReportSpec;
|
|
242
234
|
notified?: boolean;
|
|
235
|
+
completionObservedAt?: number;
|
|
243
236
|
startedAt?: number;
|
|
244
237
|
// subagent
|
|
245
238
|
task?: string;
|
|
@@ -248,6 +241,7 @@ interface Meta {
|
|
|
248
241
|
}
|
|
249
242
|
|
|
250
243
|
const metaDir = () => path.join(ROOT, "meta");
|
|
244
|
+
const logPath = (id: string) => path.join(ROOT, "sessions", id, "output.log");
|
|
251
245
|
|
|
252
246
|
function writeMeta(id: string, m: Meta): void {
|
|
253
247
|
try {
|
|
@@ -373,11 +367,9 @@ function parseDurMs(s?: string): number | null {
|
|
|
373
367
|
// megabytes, so we also cap bytes, eliding the middle so both the head and
|
|
374
368
|
// the tail of the output stay visible.
|
|
375
369
|
|
|
376
|
-
const TAIL_MAX_BYTES = 8_000; //
|
|
370
|
+
const TAIL_MAX_BYTES = 8_000; // explicit tails / screens
|
|
371
|
+
const INLINE_OUTPUT_MAX_BYTES = 8_000; // complete output returned only below this threshold
|
|
377
372
|
const ANSWER_MAX_BYTES = 24_000; // subagent answers / error messages
|
|
378
|
-
const REPORT_MAX_INPUT_BYTES = 64 * 1024 * 1024; // analyzer input log
|
|
379
|
-
const REPORT_MAX_OUTPUT_BYTES = 12_000; // analyzer stdout/stderr returned to the agent
|
|
380
|
-
const REPORT_DEFAULT_TIMEOUT_MS = 30_000;
|
|
381
373
|
|
|
382
374
|
function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
|
|
383
375
|
const buf = Buffer.from(s, "utf8");
|
|
@@ -389,132 +381,27 @@ function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
|
|
|
389
381
|
return `${head}\n… [${buf.length - maxBytes} bytes elided] …\n${tail}`;
|
|
390
382
|
}
|
|
391
383
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
})
|
|
408
|
-
const output = fs.createWriteStream(destination);
|
|
409
|
-
let bytes = 0;
|
|
410
|
-
let finished = false;
|
|
411
|
-
const finish = (error: string | null) => {
|
|
412
|
-
if (finished) return;
|
|
413
|
-
finished = true;
|
|
414
|
-
signal?.removeEventListener("abort", abort);
|
|
415
|
-
output.end(() => resolve(error));
|
|
416
|
-
};
|
|
417
|
-
const abort = () => {
|
|
418
|
-
child.kill("SIGTERM");
|
|
419
|
-
finish("report analysis was cancelled");
|
|
420
|
-
};
|
|
421
|
-
if (signal?.aborted) return abort();
|
|
422
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
423
|
-
child.stdout?.on("data", (chunk: Buffer) => {
|
|
424
|
-
if (finished) return;
|
|
425
|
-
bytes += chunk.length;
|
|
426
|
-
if (bytes > REPORT_MAX_INPUT_BYTES) {
|
|
427
|
-
child.kill("SIGTERM");
|
|
428
|
-
finish(`process log exceeds the ${REPORT_MAX_INPUT_BYTES / 1024 / 1024} MiB report limit`);
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
output.write(chunk);
|
|
432
|
-
});
|
|
433
|
-
child.on("error", (error) => finish(`could not read process log: ${error.message}`));
|
|
434
|
-
child.on("close", (code) => {
|
|
435
|
-
if (finished) return;
|
|
436
|
-
finish(code === 0 ? null : `could not read process log (babysit log exited ${code ?? 1})`);
|
|
437
|
-
});
|
|
438
|
-
});
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
async function executeReport(spec: ReportSpec, inputPath: string, signal?: AbortSignal): Promise<ReportOutcome> {
|
|
442
|
-
const dir = path.dirname(inputPath);
|
|
443
|
-
const scriptPath = path.join(dir, spec.language === "python" ? "report.py" : spec.language === "shell" ? "report.sh" : "report.cjs");
|
|
444
|
-
const script =
|
|
445
|
-
spec.language === "javascript"
|
|
446
|
-
? `const fs = require("node:fs");\nconst FILE_CONTENT = fs.readFileSync(process.env.BABYSIT_REPORT_INPUT, "utf8");\nconst INPUT = FILE_CONTENT;\n(async () => {\n${spec.code}\n})().catch((error) => { console.error(error?.stack ?? String(error)); process.exitCode = 1; });\n`
|
|
447
|
-
: spec.language === "python"
|
|
448
|
-
? `import os\nfrom pathlib import Path\nFILE_CONTENT = Path(os.environ["BABYSIT_REPORT_INPUT"]).read_text()\nINPUT = FILE_CONTENT\n${spec.code}\n`
|
|
449
|
-
: `# Read the complete log from $BABYSIT_REPORT_INPUT.\n${spec.code}\n`;
|
|
450
|
-
fs.writeFileSync(scriptPath, script, { mode: 0o700 });
|
|
451
|
-
const command = spec.language === "javascript" ? process.execPath : spec.language === "python" ? "python3" : SHELL;
|
|
452
|
-
const args = spec.language === "shell" ? [scriptPath] : [scriptPath];
|
|
453
|
-
const timeoutMs = parseDurMs(spec.timeout) ?? REPORT_DEFAULT_TIMEOUT_MS;
|
|
454
|
-
|
|
455
|
-
return new Promise((resolve) => {
|
|
456
|
-
const child = spawn(command, args, {
|
|
457
|
-
env: { ...process.env, BABYSIT_REPORT_INPUT: inputPath },
|
|
458
|
-
});
|
|
459
|
-
let stdout = "";
|
|
460
|
-
let stderr = "";
|
|
461
|
-
let outputTruncated = false;
|
|
462
|
-
let timedOut = false;
|
|
463
|
-
let settled = false;
|
|
464
|
-
const append = (current: string, chunk: Buffer) => {
|
|
465
|
-
const remaining = REPORT_MAX_OUTPUT_BYTES - Buffer.byteLength(current);
|
|
466
|
-
if (remaining <= 0) {
|
|
467
|
-
outputTruncated = true;
|
|
468
|
-
return current;
|
|
469
|
-
}
|
|
470
|
-
const text = chunk.subarray(0, remaining).toString("utf8");
|
|
471
|
-
if (text.length < chunk.length) outputTruncated = true;
|
|
472
|
-
return current + text;
|
|
473
|
-
};
|
|
474
|
-
const finish = (code: number | null) => {
|
|
475
|
-
if (settled) return;
|
|
476
|
-
settled = true;
|
|
477
|
-
clearTimeout(timer);
|
|
478
|
-
signal?.removeEventListener("abort", abort);
|
|
479
|
-
const body = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
|
|
480
|
-
const suffix = outputTruncated ? "\n[report output truncated]" : "";
|
|
481
|
-
if (timedOut) {
|
|
482
|
-
resolve({ ok: false, text: `Report timed out after ${Math.ceil(timeoutMs / 1000)}s.${body ? `\n${clip(body, REPORT_MAX_OUTPUT_BYTES)}` : ""}${suffix}` });
|
|
483
|
-
} else if (code === 0) {
|
|
484
|
-
resolve({ ok: true, text: `${body || "(report produced no output)"}${suffix}` });
|
|
485
|
-
} else {
|
|
486
|
-
resolve({ ok: false, text: `Report failed (exit ${code ?? 1}).${body ? `\n${clip(body, REPORT_MAX_OUTPUT_BYTES)}` : ""}${suffix}` });
|
|
487
|
-
}
|
|
488
|
-
};
|
|
489
|
-
const abort = () => child.kill("SIGTERM");
|
|
490
|
-
const timer = setTimeout(() => {
|
|
491
|
-
timedOut = true;
|
|
492
|
-
child.kill("SIGTERM");
|
|
493
|
-
}, timeoutMs);
|
|
494
|
-
if (signal?.aborted) abort();
|
|
495
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
496
|
-
child.stdout?.on("data", (chunk: Buffer) => { stdout = append(stdout, chunk); });
|
|
497
|
-
child.stderr?.on("data", (chunk: Buffer) => { stderr = append(stderr, chunk); });
|
|
498
|
-
child.on("error", (error) => {
|
|
499
|
-
stderr = append(stderr, Buffer.from(error.message));
|
|
500
|
-
finish(1);
|
|
501
|
-
});
|
|
502
|
-
child.on("close", finish);
|
|
503
|
-
});
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
async function runReport(id: string, spec: ReportSpec, signal?: AbortSignal): Promise<ReportOutcome> {
|
|
507
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-babysit-report-"));
|
|
508
|
-
try {
|
|
509
|
-
const inputPath = path.join(dir, "process.log");
|
|
510
|
-
const error = await saveSessionLog(id, inputPath, signal);
|
|
511
|
-
if (error) return { ok: false, text: `Could not prepare report input: ${error}` };
|
|
512
|
-
return await executeReport(spec, inputPath, signal);
|
|
513
|
-
} finally {
|
|
514
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
384
|
+
async function inlineOutput(id: string, status: BsSession): Promise<string> {
|
|
385
|
+
let bytes = status.output_bytes;
|
|
386
|
+
if (bytes == null) {
|
|
387
|
+
try {
|
|
388
|
+
bytes = fs.statSync(logPath(id)).size;
|
|
389
|
+
} catch {
|
|
390
|
+
bytes = Number.POSITIVE_INFINITY;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (bytes > INLINE_OUTPUT_MAX_BYTES) {
|
|
394
|
+
const size = Number.isFinite(bytes) ? `${bytes} bytes` : "size unavailable";
|
|
395
|
+
return `\nOutput omitted (${size}; inline limit ${INLINE_OUTPUT_MAX_BYTES}).`;
|
|
396
|
+
}
|
|
397
|
+
const output = (await bs(["log", "-s", id])).stdout.trimEnd();
|
|
398
|
+
if (Buffer.byteLength(output) > INLINE_OUTPUT_MAX_BYTES) {
|
|
399
|
+
return `\nOutput omitted (exceeds inline limit ${INLINE_OUTPUT_MAX_BYTES} bytes).`;
|
|
515
400
|
}
|
|
401
|
+
return output ? `\n\nOutput:\n${output}` : "";
|
|
516
402
|
}
|
|
517
403
|
|
|
404
|
+
|
|
518
405
|
// ---------------------------------------------------------------------------
|
|
519
406
|
// parked-turn detection (shared rule with self-reap.ts)
|
|
520
407
|
// ---------------------------------------------------------------------------
|
|
@@ -691,7 +578,6 @@ interface ProcOpts {
|
|
|
691
578
|
timeout?: string; // default: none — dev servers may run indefinitely
|
|
692
579
|
idleTimeout?: string;
|
|
693
580
|
pty: boolean;
|
|
694
|
-
report?: ReportSpec;
|
|
695
581
|
}
|
|
696
582
|
|
|
697
583
|
async function spawnProcess(opts: ProcOpts): Promise<{ id: string } | { error: string }> {
|
|
@@ -721,7 +607,6 @@ async function spawnProcess(opts: ProcOpts): Promise<{ id: string } | { error: s
|
|
|
721
607
|
kind: "process",
|
|
722
608
|
name: opts.name ?? id,
|
|
723
609
|
command: opts.command,
|
|
724
|
-
report: opts.report,
|
|
725
610
|
notified: false,
|
|
726
611
|
startedAt: Date.now(),
|
|
727
612
|
});
|
|
@@ -970,7 +855,7 @@ interface WaitOutcome {
|
|
|
970
855
|
// Wait for ONE subagent's current task. Completion = an agent_end whose last
|
|
971
856
|
// message is NOT a parked babysit_run/process toolResult (that one only means
|
|
972
857
|
// "turn parked, waiting for a background process — pi resumes on its own").
|
|
973
|
-
// Loop: analyze the current-task log slice; done →
|
|
858
|
+
// Loop: analyze the current-task log slice; done → return; still going →
|
|
974
859
|
// block on the next agent_end via `babysit expect` (race-free byte offsets).
|
|
975
860
|
async function waitForTask(
|
|
976
861
|
id: string,
|
|
@@ -1089,6 +974,14 @@ function suppressNotify(id: string): void {
|
|
|
1089
974
|
}
|
|
1090
975
|
}
|
|
1091
976
|
|
|
977
|
+
function enableNotify(id: string): void {
|
|
978
|
+
const meta = readMeta(id);
|
|
979
|
+
if (meta && meta.kind === "process" && meta.notified) {
|
|
980
|
+
meta.notified = false;
|
|
981
|
+
writeMeta(id, meta);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
1092
985
|
// Wait for a PROCESS session: either until a regex appears in its output
|
|
1093
986
|
// (`expect` — e.g. "server listening") or until the process exits.
|
|
1094
987
|
async function waitForExit(
|
|
@@ -1105,12 +998,11 @@ async function waitForExit(
|
|
|
1105
998
|
return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
|
|
1106
999
|
}
|
|
1107
1000
|
if (e.code === 0) {
|
|
1108
|
-
const tail = clip((await bs(["log", "-s", id, "--tail", "10"])).stdout.trim());
|
|
1109
1001
|
return {
|
|
1110
1002
|
id,
|
|
1111
1003
|
kind: "done",
|
|
1112
1004
|
ok: true,
|
|
1113
|
-
text: `Pattern /${expectPattern}/ matched in ${id} output (process still running).\
|
|
1005
|
+
text: `Pattern /${expectPattern}/ matched in ${id} output (process still running).\nLog: ${logPath(id)}`,
|
|
1114
1006
|
};
|
|
1115
1007
|
}
|
|
1116
1008
|
const st0 = await statusOf(id);
|
|
@@ -1125,14 +1017,19 @@ async function waitForExit(
|
|
|
1125
1017
|
}
|
|
1126
1018
|
// fall through: session exited before the pattern appeared
|
|
1127
1019
|
} else {
|
|
1020
|
+
// An explicit wait owns completion delivery. Mark it before blocking so
|
|
1021
|
+
// the exit poller cannot race us and inject a duplicate notification.
|
|
1022
|
+
suppressNotify(id);
|
|
1128
1023
|
const w = await bs(["wait", "-s", id, "--timeout", t], { signal });
|
|
1129
1024
|
if (signal?.aborted || w.code === 130) {
|
|
1025
|
+
enableNotify(id);
|
|
1130
1026
|
return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
|
|
1131
1027
|
}
|
|
1132
1028
|
if (w.code === 124) {
|
|
1133
1029
|
// 124 is ambiguous (timeout vs child exiting 124) — disambiguate.
|
|
1134
1030
|
const st0 = await statusOf(id);
|
|
1135
1031
|
if (st0?.state === "running") {
|
|
1032
|
+
enableNotify(id);
|
|
1136
1033
|
return {
|
|
1137
1034
|
id,
|
|
1138
1035
|
kind: "timeout",
|
|
@@ -1150,20 +1047,23 @@ async function waitForExit(
|
|
|
1150
1047
|
}
|
|
1151
1048
|
suppressNotify(id); // the agent sees the exit here; don't notify again
|
|
1152
1049
|
const meta = readMeta(id);
|
|
1153
|
-
const
|
|
1154
|
-
const tail = report ? "" : clip((await bs(["log", "-s", id, "--tail", "20"])).stdout.trim());
|
|
1050
|
+
const workerDead = st.state === "dead" && st.exit_code == null;
|
|
1155
1051
|
const ok = st.exit_code === 0;
|
|
1052
|
+
const output = await inlineOutput(id, st);
|
|
1156
1053
|
return {
|
|
1157
1054
|
id,
|
|
1158
1055
|
kind: "exited",
|
|
1159
1056
|
ok,
|
|
1160
1057
|
text:
|
|
1161
1058
|
`Process ${id}${meta?.command ? ` (${meta.command})` : ""} ` +
|
|
1162
|
-
|
|
1059
|
+
(workerDead
|
|
1060
|
+
? "worker-dead: the babysit supervisor disappeared without an exit status"
|
|
1061
|
+
: ok ? "completed successfully" : `exited with code ${st.exit_code ?? "?"}`) +
|
|
1163
1062
|
`${expectPattern ? ` before /${expectPattern}/ appeared` : ""}.` +
|
|
1164
|
-
(
|
|
1165
|
-
?
|
|
1166
|
-
:
|
|
1063
|
+
(workerDead
|
|
1064
|
+
? " This commonly indicates an external kill (for example endpoint security). The command may have started, so retry only if it is safe and idempotent."
|
|
1065
|
+
: "") +
|
|
1066
|
+
`\nLog: ${logPath(id)}` + output,
|
|
1167
1067
|
status: st,
|
|
1168
1068
|
};
|
|
1169
1069
|
}
|
|
@@ -1210,11 +1110,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
1210
1110
|
if (s.state === "running") continue;
|
|
1211
1111
|
const meta = readMeta(s.id);
|
|
1212
1112
|
if (!meta || meta.kind !== "process" || meta.notified) continue;
|
|
1113
|
+
// Delay delivery by one poll interval. This gives an agent that chose
|
|
1114
|
+
// babysit_wait immediately after babysit_run enough time to claim the
|
|
1115
|
+
// completion and suppress the otherwise duplicate automatic message.
|
|
1116
|
+
if (!meta.completionObservedAt) {
|
|
1117
|
+
meta.completionObservedAt = Date.now();
|
|
1118
|
+
writeMeta(s.id, meta);
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
if (Date.now() - meta.completionObservedAt < POLL_MS) continue;
|
|
1213
1122
|
meta.notified = true;
|
|
1214
1123
|
writeMeta(s.id, meta);
|
|
1215
|
-
const report = meta.report ? await runReport(s.id, meta.report) : null;
|
|
1216
|
-
const tail = report ? "" : clip((await bs(["log", "-s", s.id, "--tail", "20"])).stdout.trim());
|
|
1217
1124
|
const ok = s.exit_code === 0;
|
|
1125
|
+
const output = await inlineOutput(s.id, s);
|
|
1218
1126
|
const runtime = meta.startedAt
|
|
1219
1127
|
? `${Math.round((Date.now() - meta.startedAt) / 1000)}s`
|
|
1220
1128
|
: "?";
|
|
@@ -1227,13 +1135,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1227
1135
|
{
|
|
1228
1136
|
customType: "pi-babysit-process-end",
|
|
1229
1137
|
content:
|
|
1230
|
-
`${summary}\nCommand: ${meta.command ?? "?"}` +
|
|
1231
|
-
|
|
1232
|
-
? `\n\nReport${report.ok ? "" : " (failed)"}:\n${report.text}`
|
|
1233
|
-
: tail ? `\n\nRecent output:\n${tail}` : "") +
|
|
1234
|
-
`\n\nThis is the automatic process-end notification. Do not call babysit_check just to re-verify; use it once only if you need more logs for debugging.`,
|
|
1138
|
+
`${summary}\nCommand: ${meta.command ?? "?"}\nLog: ${logPath(s.id)}${output}` +
|
|
1139
|
+
`\n\nThis is the automatic process-end notification. Do not call babysit_check just to re-verify. Inspect the log only when needed, using bounded commands such as tail or rg; never read it in full.`,
|
|
1235
1140
|
display: true,
|
|
1236
|
-
details: { id: s.id, exitCode: s.exit_code, success: ok, runtime,
|
|
1141
|
+
details: { id: s.id, exitCode: s.exit_code, success: ok, runtime, logPath: logPath(s.id) },
|
|
1237
1142
|
},
|
|
1238
1143
|
{ triggerTurn: true, deliverAs: "steer" },
|
|
1239
1144
|
);
|
|
@@ -1342,22 +1247,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
1342
1247
|
name: "babysit_run",
|
|
1343
1248
|
label: "Babysit: run",
|
|
1344
1249
|
description:
|
|
1345
|
-
"
|
|
1346
|
-
"
|
|
1347
|
-
"
|
|
1348
|
-
"
|
|
1349
|
-
"
|
|
1350
|
-
"
|
|
1351
|
-
"
|
|
1250
|
+
"Run any shell command in a supervised babysit session. Commands that finish within a short " +
|
|
1251
|
+
"grace period return completion metadata immediately; longer commands continue in the background " +
|
|
1252
|
+
"and trigger an automatic notification on exit. Complete output is returned inline only when it is " +
|
|
1253
|
+
"small; larger output stays in the log path for bounded inspection with tail or rg. " +
|
|
1254
|
+
"In non-interactive mode (`pi -p`, no UI), process mode blocks until exit because there is no " +
|
|
1255
|
+
"notification loop. Two modes: (1) `command` — run any shell command, including builds, tests, " +
|
|
1256
|
+
"dev servers, watchers, and interactive TUIs; you can type into it with babysit_send and read " +
|
|
1257
|
+
"its screen with babysit_check. If endpoint security kills a worker at startup, " +
|
|
1258
|
+
"`retryOnWorkerDeath` can retry one idempotent command once. " +
|
|
1352
1259
|
"(2) `profile: \"subagent\"` + `task` — spawn a pi subagent that works on the task in the " +
|
|
1353
1260
|
"background; poll with babysit_check, steer with babysit_send, block with babysit_wait, " +
|
|
1354
1261
|
"stop with babysit_kill.",
|
|
1355
1262
|
promptSnippet:
|
|
1356
|
-
"Run
|
|
1263
|
+
"Run any shell command with context-safe captured output; quick commands return metadata, longer ones continue in background",
|
|
1357
1264
|
promptGuidelines: [
|
|
1358
|
-
"
|
|
1265
|
+
"Use babysit_run as the default for shell commands, not only long-running work. Small output is returned directly; large stdout/stderr stays out of model context in the returned log path. Give meaningful commands a clear stable `name`.",
|
|
1266
|
+
"Inspect a babysit log only with explicitly bounded commands such as `tail -n N` or focused `rg`; never read or cat a potentially large log in full. Those small inspection commands may use bash directly to avoid recursively creating babysit sessions.",
|
|
1359
1267
|
"After babysit_run { command } starts a process, end your response immediately so the automatic process-end notification can resume you; NEVER poll with babysit_check or sleep. Set continueAfterStart: true only when you have immediate, specific, non-polling work to do next. Call babysit_wait when you must consume the result inside the current turn (optionally with `expect` to wait for a readiness line like 'listening on').",
|
|
1360
|
-
"
|
|
1268
|
+
"If a babysit worker is killed externally, babysit_run reports it as worker-dead rather than hanging. Set retryOnWorkerDeath: true only for safe, idempotent commands; it retries at most once and may otherwise duplicate side effects.",
|
|
1361
1269
|
"babysit_run gives full PTY control: drive interactive programs (installers, wizards, REPLs) with babysit_send (text or named keys) and read the rendered screen with babysit_check { screen: true }.",
|
|
1362
1270
|
"Delegate self-contained tasks (codebase recon, a parallelizable subtask, work that would pollute your context) with babysit_run { profile: \"subagent\", task }. Launch several for independent subtasks; they run concurrently.",
|
|
1363
1271
|
"After spawning subagents, do not idle-wait and do not end your turn to wait for them: keep making progress, then call babysit_wait (ids + mode any/all) when you need their results. Steer or send follow-up tasks with babysit_send; kill runaways with babysit_kill.",
|
|
@@ -1417,20 +1325,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1417
1325
|
"Process mode only. Default false: starting a process ENDS the current turn (you are resumed by the exit notification). Set true only when you have immediate, specific, non-polling work to do after starting.",
|
|
1418
1326
|
}),
|
|
1419
1327
|
),
|
|
1420
|
-
|
|
1421
|
-
Type.
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
}),
|
|
1425
|
-
code: Type.String({
|
|
1426
|
-
description:
|
|
1427
|
-
"Program that analyzes the complete captured log. JavaScript/Python receive FILE_CONTENT and INPUT strings; shell receives $BABYSIT_REPORT_INPUT. Print only the concise report.",
|
|
1428
|
-
}),
|
|
1429
|
-
timeout: Type.Optional(
|
|
1430
|
-
Type.String({ description: "Report-program timeout (default 30s, e.g. '10s')." }),
|
|
1431
|
-
),
|
|
1328
|
+
retryOnWorkerDeath: Type.Optional(
|
|
1329
|
+
Type.Boolean({
|
|
1330
|
+
description:
|
|
1331
|
+
"Process mode only. Retry once if the babysit worker is killed externally during startup. Use only for safe, idempotent commands because the first attempt may have produced side effects.",
|
|
1432
1332
|
}),
|
|
1433
1333
|
),
|
|
1334
|
+
|
|
1434
1335
|
}),
|
|
1435
1336
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
1436
1337
|
await requireBabysit();
|
|
@@ -1456,25 +1357,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
1456
1357
|
details: {},
|
|
1457
1358
|
};
|
|
1458
1359
|
}
|
|
1459
|
-
if (isSubagent && params.report) {
|
|
1460
|
-
return {
|
|
1461
|
-
content: [{ type: "text", text: "`report` is available only for process sessions." }],
|
|
1462
|
-
isError: true,
|
|
1463
|
-
details: {},
|
|
1464
|
-
};
|
|
1465
|
-
}
|
|
1466
1360
|
|
|
1467
1361
|
// --- process mode ---
|
|
1468
1362
|
if (!isSubagent) {
|
|
1469
|
-
const
|
|
1363
|
+
const spawnOpts: ProcOpts = {
|
|
1470
1364
|
name: params.name,
|
|
1471
1365
|
command: params.command as string,
|
|
1472
1366
|
cwd: ctx.cwd,
|
|
1473
1367
|
timeout: params.timeout,
|
|
1474
1368
|
idleTimeout: params.idleTimeout,
|
|
1475
1369
|
pty: params.pty ?? true,
|
|
1476
|
-
|
|
1477
|
-
|
|
1370
|
+
};
|
|
1371
|
+
let res = await spawnProcess(spawnOpts);
|
|
1478
1372
|
if ("error" in res) {
|
|
1479
1373
|
return {
|
|
1480
1374
|
content: [{ type: "text", text: `Failed to start process: ${res.error}` }],
|
|
@@ -1492,11 +1386,46 @@ export default function (pi: ExtensionAPI) {
|
|
|
1492
1386
|
// outcome in THIS turn. The process still runs under babysit (logged,
|
|
1493
1387
|
// killable), we just wait for it here instead of fire-and-forget.
|
|
1494
1388
|
if (!ctx.hasUI) {
|
|
1495
|
-
|
|
1389
|
+
let outcome = await waitForExit(res.id, parseDurMs(params.timeout), _signal);
|
|
1390
|
+
let retried = false;
|
|
1391
|
+
if (params.retryOnWorkerDeath && outcome.status?.state === "dead" && outcome.status.exit_code == null) {
|
|
1392
|
+
const retry = await spawnProcess(spawnOpts);
|
|
1393
|
+
if (!("error" in retry)) {
|
|
1394
|
+
res = retry;
|
|
1395
|
+
retried = true;
|
|
1396
|
+
outcome = await waitForExit(res.id, parseDurMs(params.timeout), _signal);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1496
1399
|
return {
|
|
1497
|
-
content: [{ type: "text", text: outcome.text }],
|
|
1400
|
+
content: [{ type: "text", text: `${retried ? "Retried once after external worker death.\n" : ""}${outcome.text}` }],
|
|
1498
1401
|
isError: !outcome.ok,
|
|
1499
|
-
details: { id: res.id, kind: "process", command: params.command,
|
|
1402
|
+
details: { id: res.id, kind: "process", command: params.command, logPath: logPath(res.id), retried },
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// Keep ordinary quick commands ergonomic. Give the process a short grace
|
|
1407
|
+
// period; if it exits, return only lifecycle metadata + log path now.
|
|
1408
|
+
// A timeout means it is genuinely background work and follows the normal
|
|
1409
|
+
// parked-turn / automatic-notification contract below.
|
|
1410
|
+
await bs(["wait", "-s", res.id, "--timeout", QUICK_COMMAND_GRACE], { signal: _signal });
|
|
1411
|
+
let quickStatus = await statusOf(res.id);
|
|
1412
|
+
let retried = false;
|
|
1413
|
+
if (params.retryOnWorkerDeath && quickStatus?.state === "dead" && quickStatus.exit_code == null) {
|
|
1414
|
+
const retry = await spawnProcess(spawnOpts);
|
|
1415
|
+
if (!("error" in retry)) {
|
|
1416
|
+
res = retry;
|
|
1417
|
+
retried = true;
|
|
1418
|
+
await bs(["wait", "-s", res.id, "--timeout", QUICK_COMMAND_GRACE], { signal: _signal });
|
|
1419
|
+
quickStatus = await statusOf(res.id);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
if (quickStatus && quickStatus.state !== "running") {
|
|
1423
|
+
const outcome = await waitForExit(res.id, null, _signal);
|
|
1424
|
+
await refreshWidget(ctx);
|
|
1425
|
+
return {
|
|
1426
|
+
content: [{ type: "text", text: `${retried ? "Retried once after external worker death.\n" : ""}${outcome.text}` }],
|
|
1427
|
+
isError: !outcome.ok,
|
|
1428
|
+
details: { id: res.id, kind: "process", command: params.command, logPath: logPath(res.id), retried },
|
|
1500
1429
|
};
|
|
1501
1430
|
}
|
|
1502
1431
|
|
|
@@ -1509,13 +1438,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
1509
1438
|
{
|
|
1510
1439
|
type: "text",
|
|
1511
1440
|
text:
|
|
1512
|
-
|
|
1441
|
+
`${retried ? "Retried once after external worker death.\n" : ""}` +
|
|
1442
|
+
`Process started (id: ${res.id}). ${NOTIFY_MARKER}\nLog: ${logPath(res.id)}\n${nextStep}\n` +
|
|
1513
1443
|
`Inspect: babysit_check { id: "${res.id}" } (screen: true for TUIs) · ` +
|
|
1514
1444
|
`Wait: babysit_wait { id: "${res.id}" } · Kill: babysit_kill { id: "${res.id}" }\n` +
|
|
1515
1445
|
`Human can watch/take over: /babysit`,
|
|
1516
1446
|
},
|
|
1517
1447
|
],
|
|
1518
|
-
details: { id: res.id, kind: "process", command: params.command,
|
|
1448
|
+
details: { id: res.id, kind: "process", command: params.command, logPath: logPath(res.id), retried },
|
|
1519
1449
|
// Do not return `terminate: true` here. In RPC/subagent hosts that hint
|
|
1520
1450
|
// can shut down the hosting pi worker, whose process-tree cleanup then
|
|
1521
1451
|
// kills the otherwise detached babysit supervisor and closes its PTY
|
|
@@ -1652,6 +1582,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1652
1582
|
}
|
|
1653
1583
|
if (st.exit_code != null) header += ` exit_code=${st.exit_code}`;
|
|
1654
1584
|
if (meta?.command) header += `\ncommand: ${meta.command}`;
|
|
1585
|
+
header += `\nlog: ${logPath(params.id)}`;
|
|
1655
1586
|
if (st.note) header += ` ⚑ ${st.note}`;
|
|
1656
1587
|
parts.push(header);
|
|
1657
1588
|
if (params.screen) {
|
|
@@ -1666,7 +1597,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1666
1597
|
}
|
|
1667
1598
|
return {
|
|
1668
1599
|
content: [{ type: "text", text: parts.join("\n") }],
|
|
1669
|
-
details: { status: st, kind: "process" },
|
|
1600
|
+
details: { status: st, kind: "process", logPath: logPath(params.id) },
|
|
1670
1601
|
};
|
|
1671
1602
|
}
|
|
1672
1603
|
|
|
@@ -1876,62 +1807,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1876
1807
|
},
|
|
1877
1808
|
});
|
|
1878
1809
|
|
|
1879
|
-
// ----- babysit_analyze ----------------------------------------------------
|
|
1880
|
-
pi.registerTool({
|
|
1881
|
-
name: "babysit_analyze",
|
|
1882
|
-
label: "Babysit: analyze",
|
|
1883
|
-
description:
|
|
1884
|
-
"Run a local analyzer over a babysit process's complete captured log. The raw log remains outside " +
|
|
1885
|
-
"the model context; only the analyzer's bounded stdout is returned. JavaScript/Python receive " +
|
|
1886
|
-
"FILE_CONTENT and INPUT strings; shell receives $BABYSIT_REPORT_INPUT. It can inspect a running " +
|
|
1887
|
-
"session's output so far or a completed session.",
|
|
1888
|
-
promptSnippet: "Analyze a babysit process log locally and return only a concise report",
|
|
1889
|
-
promptGuidelines: [
|
|
1890
|
-
"Use babysit_analyze to extract failures, counts, or other concise facts from a large babysit process log instead of calling babysit_check repeatedly or returning raw logs.",
|
|
1891
|
-
],
|
|
1892
|
-
parameters: Type.Object({
|
|
1893
|
-
id: Type.String({ description: "Process session id whose captured log should be analyzed." }),
|
|
1894
|
-
language: StringEnum(["javascript", "python", "shell"] as const, {
|
|
1895
|
-
description: "Language for the local analyzer program.",
|
|
1896
|
-
}),
|
|
1897
|
-
code: Type.String({
|
|
1898
|
-
description:
|
|
1899
|
-
"Analyzer program. JavaScript/Python receive FILE_CONTENT and INPUT strings; shell reads $BABYSIT_REPORT_INPUT. Print only the concise result.",
|
|
1900
|
-
}),
|
|
1901
|
-
timeout: Type.Optional(
|
|
1902
|
-
Type.String({ description: "Analyzer timeout (default 30s, e.g. '10s')." }),
|
|
1903
|
-
),
|
|
1904
|
-
}),
|
|
1905
|
-
async execute(_id, params, signal) {
|
|
1906
|
-
await requireBabysit();
|
|
1907
|
-
const status = await statusOf(params.id);
|
|
1908
|
-
if (!status) {
|
|
1909
|
-
return {
|
|
1910
|
-
content: [{ type: "text", text: `No such session: ${params.id}` }],
|
|
1911
|
-
isError: true,
|
|
1912
|
-
details: {},
|
|
1913
|
-
};
|
|
1914
|
-
}
|
|
1915
|
-
if (kindOf(params.id) === "subagent") {
|
|
1916
|
-
return {
|
|
1917
|
-
content: [{ type: "text", text: "babysit_analyze supports process sessions only; use babysit_check for subagent progress." }],
|
|
1918
|
-
isError: true,
|
|
1919
|
-
details: {},
|
|
1920
|
-
};
|
|
1921
|
-
}
|
|
1922
|
-
const report = await runReport(params.id, {
|
|
1923
|
-
language: params.language,
|
|
1924
|
-
code: params.code,
|
|
1925
|
-
timeout: params.timeout,
|
|
1926
|
-
}, signal);
|
|
1927
|
-
return {
|
|
1928
|
-
content: [{ type: "text", text: report.text }],
|
|
1929
|
-
isError: !report.ok,
|
|
1930
|
-
details: {},
|
|
1931
|
-
};
|
|
1932
|
-
},
|
|
1933
|
-
});
|
|
1934
|
-
|
|
1935
1810
|
// ----- babysit_wait -------------------------------------------------------
|
|
1936
1811
|
pi.registerTool({
|
|
1937
1812
|
name: "babysit_wait",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yusukeshib/pi-babysit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Run
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"pi-extension",
|