@yusukeshib/pi-babysit 0.2.0

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/index.ts ADDED
@@ -0,0 +1,1881 @@
1
+ /**
2
+ * pi-babysit: run ANYTHING long-lived under babysit — one supervision substrate
3
+ * for background processes AND pi subagents. Retires both `pi-processes`
4
+ * (the `process` tool) and `pi-subagent`.
5
+ *
6
+ * Every session is a babysit-supervised PTY (state in $PI_BABYSIT_DIR,
7
+ * default ~/.pi-babysit, namespaced per pi session). Two KINDS of session:
8
+ *
9
+ * kind=process `babysit_run { command }` — builds, tests, dev servers,
10
+ * watchers, interactive TUIs. Completion = process exit.
11
+ * On exit a notification message is injected (triggerTurn),
12
+ * so the agent can END ITS TURN after starting and be resumed
13
+ * automatically — same contract as the old `process` tool.
14
+ * Being a PTY, the agent can also TYPE into it (babysit_send
15
+ * text/keys) and read the rendered screen (babysit_check
16
+ * { screen: true }) — full interactive-program driving.
17
+ *
18
+ * kind=subagent `babysit_run { profile: "subagent", task }` — a long-lived
19
+ * `pi --mode rpc` worker. Tasks are injected as RPC `prompt`
20
+ * commands over stdin, completion is detected from the JSONL
21
+ * event stream (`agent_end`), NOT process exit; the session
22
+ * stays alive for cheap follow-up tasks. Same design as the
23
+ * old pi-subagent extension.
24
+ *
25
+ * The "profile" is a tool-parameter, not a separate tool set: one small tool
26
+ * surface (babysit_run/check/send/wait/kill) covers both, and domain knowledge
27
+ * (RPC bookkeeping, byte offsets, parked-turn detection) stays in code.
28
+ *
29
+ * Tools (LLM): babysit_run, babysit_check, babysit_send, babysit_wait, babysit_kill
30
+ * Commands: /babysit (arrow-key picker: attach/tail/inspect)
31
+ * Widget: live counts (processes running · subagents working · idle)
32
+ */
33
+
34
+ import { spawn } from "node:child_process";
35
+ import * as fs from "node:fs";
36
+ import * as os from "node:os";
37
+ import * as path from "node:path";
38
+ import { fileURLToPath } from "node:url";
39
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
40
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
41
+ import { Box, Markdown, Text } from "@earendil-works/pi-tui";
42
+ import { Type } from "typebox";
43
+ import { StringEnum } from "@earendil-works/pi-ai";
44
+ import { type AgentConfig, type AgentScope, discoverAgents } from "./agents";
45
+
46
+ // Dedicated babysit state root so pi-managed sessions never collide with the
47
+ // user's own manual `babysit` sessions. The base is namespaced per pi session
48
+ // on session_start (BABYSIT_DIR=<base>/<session-id>), so each pi session only
49
+ // sees its own sessions in list/widget/kill.
50
+ const ROOT_BASE = process.env.PI_BABYSIT_DIR ?? path.join(os.homedir(), ".pi-babysit");
51
+ let ROOT = ROOT_BASE;
52
+ const PI_BIN = process.env.PI_BABYSIT_BIN ?? "pi";
53
+ const BABYSIT_BIN = process.env.PI_BABYSIT_CLI ?? "babysit";
54
+ const SHELL = process.env.SHELL ?? "sh";
55
+
56
+ // Marker embedded in babysit_run's tool RESULT text for kind=process runs.
57
+ // It is how "the turn parked awaiting a process-exit notification" is told
58
+ // apart from any other turn end (see isParkedToolResult / self-reap.ts).
59
+ export const NOTIFY_MARKER = "[notify-on-exit]";
60
+
61
+ // Human-readable view for the subagent JSONL stream when a human attaches.
62
+ // babysit records the raw JSON to its log (parsers unaffected) and pipes only
63
+ // the live attach view through this formatter. Set PI_BABYSIT_VIEW_CMD="" to
64
+ // disable (raw JSON), or to a custom command to override.
65
+ const EXT_DIR = path.dirname(fileURLToPath(import.meta.url));
66
+ const shq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
67
+ const VIEW_CMD =
68
+ process.env.PI_BABYSIT_VIEW_CMD ??
69
+ `${shq(process.execPath)} ${shq(path.join(EXT_DIR, "format-stream.mjs"))}`;
70
+
71
+ // Appended to every subagent's system prompt. The subagent is a long-lived
72
+ // headless `pi --mode rpc` worker: turns can end and resume, so babysit_run
73
+ // (process kind) works normally inside it. It just cannot talk to a human.
74
+ const SUBAGENT_GUIDANCE = [
75
+ "You are a headless background worker driven over pi's RPC protocol.",
76
+ "Work autonomously: you cannot ask the user questions, so state assumptions",
77
+ "in your final answer instead. Long commands may be run synchronously via",
78
+ "bash (blocking is fine) or via babysit_run (it works here). When your",
79
+ "task is complete, produce a final answer message summarizing the outcome —",
80
+ "your controller reads it from the event stream.",
81
+ ].join(" ");
82
+ const POLL_MS = 2500;
83
+
84
+ interface BsSession {
85
+ id: string;
86
+ state: string; // "running" | "exited" | "dead" ...
87
+ alive?: boolean; // whether the PTY worker process is still live
88
+ exit_code?: number | null;
89
+ note?: string | null;
90
+ output_bytes?: number;
91
+ screen_seq?: number | null;
92
+ }
93
+
94
+ // A worker whose PTY process is gone (alive:false) can still report state
95
+ // "running" if it crashed BEFORE recording its exit transition (e.g. the child
96
+ // died in the first few ms). Treat that as finished so it never shows or counts
97
+ // as running. This normalizes at the source so every downstream
98
+ // `state === "running"` check is correct.
99
+ function normalizeSession(s: BsSession): BsSession {
100
+ if (s.alive === false && s.state === "running") {
101
+ return { ...s, state: s.exit_code != null ? "exited" : "dead" };
102
+ }
103
+ return s;
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // babysit CLI helpers
108
+ // ---------------------------------------------------------------------------
109
+
110
+ // Async, NON-BLOCKING spawn. Using spawnSync here would block Node's event
111
+ // loop (and thus freeze the whole TUI) for the entire duration of the child —
112
+ // which for `babysit wait` can be minutes or forever. A streamed async spawn
113
+ // keeps the UI responsive while the child runs. An optional AbortSignal lets a
114
+ // long wait be interrupted (Ctrl-C) by killing the child.
115
+ function bs(
116
+ args: string[],
117
+ opts: { cwd?: string; signal?: AbortSignal } = {},
118
+ ): Promise<{ stdout: string; stderr: string; code: number }> {
119
+ return new Promise((resolve) => {
120
+ if (opts.signal?.aborted) {
121
+ resolve({ stdout: "", stderr: "aborted", code: 130 });
122
+ return;
123
+ }
124
+ const child = spawn(BABYSIT_BIN, args, {
125
+ cwd: opts.cwd,
126
+ env: { ...process.env, BABYSIT_DIR: ROOT },
127
+ });
128
+ let stdout = "";
129
+ let stderr = "";
130
+ const onAbort = () => child.kill("SIGTERM");
131
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
132
+ child.stdout?.on("data", (d) => {
133
+ stdout += d.toString();
134
+ });
135
+ child.stderr?.on("data", (d) => {
136
+ stderr += d.toString();
137
+ });
138
+ child.on("error", (e) => {
139
+ opts.signal?.removeEventListener("abort", onAbort);
140
+ resolve({ stdout, stderr: stderr + String(e), code: 1 });
141
+ });
142
+ child.on("close", (code) => {
143
+ opts.signal?.removeEventListener("abort", onAbort);
144
+ resolve({ stdout, stderr, code: code ?? 1 });
145
+ });
146
+ });
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // preflight: the `babysit` binary must be on PATH
151
+ // ---------------------------------------------------------------------------
152
+
153
+ // Every session shells out to `babysit`; without it the extension can do
154
+ // nothing. We don't auto-install (that's the user's job) — we fail loudly with
155
+ // install instructions the moment a tool or command is used.
156
+ const INSTALL_HINT =
157
+ `The \`babysit\` binary was not found (tried "${BABYSIT_BIN}").\n` +
158
+ `Install it, then retry:\n` +
159
+ ` cargo install --git https://github.com/yusukeshib/babysit\n` +
160
+ `or download a prebuilt binary from https://github.com/yusukeshib/babysit/releases and put it on your PATH.\n` +
161
+ `(Override the binary path with $PI_BABYSIT_CLI.)`;
162
+
163
+ // Cached preflight — probe `babysit --version` exactly once per process.
164
+ let babysitOk: boolean | undefined;
165
+ async function babysitAvailable(): Promise<boolean> {
166
+ if (babysitOk === undefined) {
167
+ const r = await bs(["--version"]);
168
+ babysitOk = r.code === 0;
169
+ }
170
+ return babysitOk;
171
+ }
172
+
173
+ // Throwing form for tool `execute` handlers: a thrown error marks the tool
174
+ // result isError and reports the install hint to the model.
175
+ async function requireBabysit(): Promise<void> {
176
+ if (!(await babysitAvailable())) throw new Error(INSTALL_HINT);
177
+ }
178
+
179
+ // Error-aware: `babysit list` failing is NOT the same as "no sessions" —
180
+ // callers that show state to the agent must surface the error instead of
181
+ // silently reporting an empty registry (which reads like lost sessions).
182
+ async function listSessions(): Promise<{ sessions: BsSession[]; error?: string }> {
183
+ const r = await bs(["list", "--json"]);
184
+ if (r.code !== 0) {
185
+ return {
186
+ sessions: [],
187
+ error: r.stderr || r.stdout || `babysit list failed (exit ${r.code}, no output)`,
188
+ };
189
+ }
190
+ try {
191
+ const parsed = JSON.parse(r.stdout);
192
+ const raw: BsSession[] = Array.isArray(parsed) ? parsed : (parsed.sessions ?? []);
193
+ return { sessions: raw.map(normalizeSession) };
194
+ } catch {
195
+ return { sessions: [], error: `could not parse babysit list output: ${r.stdout.slice(0, 200)}` };
196
+ }
197
+ }
198
+
199
+ async function statusOf(id: string): Promise<BsSession | null> {
200
+ // `status --json` shape: { session, status: { state, exit_code, ... } }.
201
+ // `note` lives only in `list --json`, so fold it in from there.
202
+ const r = await bs(["status", "-s", id, "--json"]);
203
+ if (r.code !== 0) return null;
204
+ try {
205
+ const parsed = JSON.parse(r.stdout);
206
+ const inner = parsed.status ?? parsed;
207
+ // `note` and `alive` live only in `list --json`, so fold them in from there
208
+ // (alive is what lets us detect a crashed-but-"running" worker).
209
+ const listed = (await listSessions()).sessions.find((s) => s.id === id);
210
+ return normalizeSession({
211
+ id: parsed.session ?? id,
212
+ note: listed?.note ?? null,
213
+ alive: listed?.alive,
214
+ ...inner,
215
+ });
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // per-session metadata
223
+ // ---------------------------------------------------------------------------
224
+
225
+ // kind=process: name/command + `notified` (exit notification dedup).
226
+ // kind=subagent: task + the raw-log byte offset of the last prompt, which lets
227
+ // check/wait analyze only the CURRENT task's events (important for follow-ups).
228
+ interface Meta {
229
+ kind: "process" | "subagent";
230
+ // process
231
+ name?: string;
232
+ command?: string;
233
+ notified?: boolean;
234
+ startedAt?: number;
235
+ // subagent
236
+ task?: string;
237
+ promptOffset?: number;
238
+ model?: string;
239
+ }
240
+
241
+ const metaDir = () => path.join(ROOT, "meta");
242
+
243
+ function writeMeta(id: string, m: Meta): void {
244
+ try {
245
+ fs.mkdirSync(metaDir(), { recursive: true });
246
+ fs.writeFileSync(path.join(metaDir(), `${id}.json`), JSON.stringify(m));
247
+ } catch {
248
+ /* best-effort */
249
+ }
250
+ }
251
+
252
+ function readMeta(id: string): Meta | null {
253
+ try {
254
+ return JSON.parse(fs.readFileSync(path.join(metaDir(), `${id}.json`), "utf-8"));
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+
260
+ const kindOf = (id: string): "process" | "subagent" => readMeta(id)?.kind ?? "process";
261
+
262
+ // Compact elapsed formatting: "42s", "3m12s", "1h04m".
263
+ function fmtDuration(ms: number): string {
264
+ const s = Math.max(0, Math.round(ms / 1000));
265
+ if (s < 60) return `${s}s`;
266
+ const m = Math.floor(s / 60);
267
+ if (m < 60) return `${m}m${String(s % 60).padStart(2, "0")}s`;
268
+ const h = Math.floor(m / 60);
269
+ return `${h}h${String(m % 60).padStart(2, "0")}m`;
270
+ }
271
+
272
+ // Elapsed since a session started, from its recorded startedAt (null if unknown).
273
+ function elapsedOf(id: string): string | null {
274
+ const started = readMeta(id)?.startedAt;
275
+ return started ? fmtDuration(Date.now() - started) : null;
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // RPC plumbing over babysit (subagent kind: send a command, await its response)
280
+ // ---------------------------------------------------------------------------
281
+
282
+ // `babysit send --json` returns the raw-log byte offset just BEFORE the input
283
+ // was injected — pass it to `expect --since` to wait for the reply race-free.
284
+ async function sendRpc(
285
+ id: string,
286
+ cmd: Record<string, unknown>,
287
+ ): Promise<{ offset: number } | { error: string }> {
288
+ const r = await bs(["send", "-s", id, "--json", JSON.stringify(cmd)]);
289
+ if (r.code !== 0) return { error: r.stderr || r.stdout || "send failed" };
290
+ try {
291
+ return { offset: JSON.parse(r.stdout).offset as number };
292
+ } catch {
293
+ return { error: `could not parse send output: ${r.stdout}` };
294
+ }
295
+ }
296
+
297
+ // Wait for `{"type":"response","command":<command>}` after `since`, then parse
298
+ // it. Distinguishes: success, explicit failure (success:false → the error
299
+ // message, e.g. "No API key found for …"), subagent death, and timeout — this
300
+ // is what makes bad-model/config failures LOUD instead of silent.
301
+ async function rpcResponse(
302
+ id: string,
303
+ since: number,
304
+ command: string,
305
+ timeout = "30s",
306
+ signal?: AbortSignal,
307
+ ): Promise<{ ok: true; data?: Record<string, unknown> } | { ok: false; error: string }> {
308
+ const e = await bs(
309
+ ["expect", "-s", id, "--since", String(since), "--timeout", timeout, `"command":"${command}"`],
310
+ { signal },
311
+ );
312
+ if (e.code !== 0) {
313
+ const st = await statusOf(id);
314
+ if (st && st.state !== "running") {
315
+ const tail = clip((await bs(["log", "-s", id, "--tail", "15"])).stdout.trim());
316
+ return {
317
+ ok: false,
318
+ error:
319
+ `subagent exited (exit_code=${st.exit_code ?? "?"}) before responding to ${command}.` +
320
+ (tail ? `\nLast output:\n${tail}` : ""),
321
+ };
322
+ }
323
+ return {
324
+ ok: false,
325
+ error:
326
+ e.code === 124
327
+ ? `timed out waiting for ${command} response (${timeout})`
328
+ : e.stderr || `expect failed (code ${e.code})`,
329
+ };
330
+ }
331
+ const lg = await bs(["log", "-s", id, "--since", String(since)]);
332
+ for (const raw of lg.stdout.split("\n")) {
333
+ const line = raw.replace(/\r$/, "").trim();
334
+ if (!line.startsWith("{")) continue;
335
+ try {
336
+ const ev = JSON.parse(line);
337
+ if (ev.type === "response" && ev.command === command) {
338
+ if (ev.success === false) {
339
+ return { ok: false, error: clip(String(ev.error ?? `${command} failed`)) };
340
+ }
341
+ return { ok: true, data: ev.data as Record<string, unknown> | undefined };
342
+ }
343
+ } catch {
344
+ /* partial line */
345
+ }
346
+ }
347
+ return { ok: false, error: `no ${command} response found in log` };
348
+ }
349
+
350
+ // "5m" / "30s" / "2h" → milliseconds (null = no limit).
351
+ function parseDurMs(s?: string): number | null {
352
+ if (!s || s === "none" || s === "0") return null;
353
+ const m = /^(\d+)(ms|s|m|h)?$/.exec(s.trim());
354
+ if (!m) return null;
355
+ const n = Number(m[1]);
356
+ const u = m[2] ?? "s";
357
+ return n * (u === "ms" ? 1 : u === "s" ? 1000 : u === "m" ? 60_000 : 3_600_000);
358
+ }
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // Context-size guard — every string that flows back into the agent's context
362
+ // passes through clip(). Log tails are line-capped upstream (`--tail N`), but
363
+ // a single pathological line (minified JS, a giant JSON blob) can still be
364
+ // megabytes, so we also cap bytes, eliding the middle so both the head and
365
+ // the tail of the output stay visible.
366
+
367
+ const TAIL_MAX_BYTES = 8_000; // log tails / screens
368
+ const ANSWER_MAX_BYTES = 24_000; // subagent answers / error messages
369
+
370
+ function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
371
+ const buf = Buffer.from(s, "utf8");
372
+ if (buf.length <= maxBytes) return s;
373
+ const half = Math.floor(maxBytes / 2);
374
+ // Strip replacement chars from a mid-codepoint cut at the boundary.
375
+ const head = buf.subarray(0, half).toString("utf8").replace(/\uFFFD+$/, "");
376
+ const tail = buf.subarray(buf.length - half).toString("utf8").replace(/^\uFFFD+/, "");
377
+ return `${head}\n… [${buf.length - maxBytes} bytes elided] …\n${tail}`;
378
+ }
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // parked-turn detection (shared rule with self-reap.ts)
382
+ // ---------------------------------------------------------------------------
383
+
384
+ // A turn that ends right after `babysit_run { command }` only means "parked
385
+ // awaiting the process-exit notification" — pi resumes it on its own; that is
386
+ // NOT task completion. Such runs stamp NOTIFY_MARKER into their tool result,
387
+ // so the marker in the LAST message's toolResult identifies a parked turn.
388
+ // (A subagent-profile run does NOT carry the marker: ending a turn to "wait"
389
+ // for a subagent is a guidance violation, and treating it as completion keeps
390
+ // the parent from hanging forever.) `process` is the legacy pi-processes tool.
391
+ function isParkedToolResult(
392
+ last: { role?: string; toolName?: string; content?: unknown } | undefined,
393
+ ): boolean {
394
+ if (!last || last.role !== "toolResult") return false;
395
+ if (last.toolName === "process") return true; // legacy
396
+ if (last.toolName !== "babysit_run") return false;
397
+ try {
398
+ const s = JSON.stringify(last.content ?? null);
399
+ if (s === "null") return true; // content unavailable — err on "parked" (never false-kill a build wait)
400
+ return s.includes(NOTIFY_MARKER);
401
+ } catch {
402
+ return true;
403
+ }
404
+ }
405
+
406
+ // ---------------------------------------------------------------------------
407
+ // parse a subagent's RPC event stream (from its babysit log)
408
+ // ---------------------------------------------------------------------------
409
+
410
+ interface ToolCall {
411
+ name: string;
412
+ summary: string;
413
+ }
414
+ interface Progress {
415
+ turns: number;
416
+ toolCalls: ToolCall[];
417
+ finalText: string;
418
+ tokens?: number;
419
+ cost?: number;
420
+ errorMsg?: string;
421
+ // RPC lifecycle bookkeeping (computed over the analyzed log slice):
422
+ agentStarts: number;
423
+ agentEnds: number;
424
+ lastEndWasProcessWait: boolean;
425
+ running: boolean; // an agent run is in flight right now
426
+ waitingOnProcess: boolean; // idle, but a process resume is pending
427
+ done: boolean; // task genuinely complete
428
+ }
429
+
430
+ function summarizeToolCall(name: string, args: Record<string, unknown>): string {
431
+ const s = (v: unknown, n = 60) => {
432
+ const str = String(v ?? "");
433
+ return str.length > n ? `${str.slice(0, n - 1)}\u2026` : str;
434
+ };
435
+ switch (name) {
436
+ case "bash":
437
+ return `$ ${s(args.command)}`;
438
+ case "read":
439
+ return `read ${s(args.file_path ?? args.path)}`;
440
+ case "write":
441
+ return `write ${s(args.file_path ?? args.path)}`;
442
+ case "edit":
443
+ return `edit ${s(args.file_path ?? args.path)}`;
444
+ case "grep":
445
+ return `grep /${s(args.pattern, 40)}/`;
446
+ case "find":
447
+ return `find ${s(args.pattern ?? args.path, 40)}`;
448
+ case "ls":
449
+ return `ls ${s(args.path)}`;
450
+ case "babysit_run":
451
+ return `babysit ${s(args.command ?? args.task, 50)}`;
452
+ default:
453
+ return `${name} ${s(JSON.stringify(args), 40)}`;
454
+ }
455
+ }
456
+
457
+ function parseEvents(logText: string): Progress {
458
+ const p: Progress = {
459
+ turns: 0,
460
+ toolCalls: [],
461
+ finalText: "",
462
+ agentStarts: 0,
463
+ agentEnds: 0,
464
+ lastEndWasProcessWait: false,
465
+ running: false,
466
+ waitingOnProcess: false,
467
+ done: false,
468
+ };
469
+ for (const raw of logText.split("\n")) {
470
+ const line = raw.replace(/\r$/, "").trim();
471
+ if (!line.startsWith("{")) continue;
472
+ let ev: Record<string, unknown>;
473
+ try {
474
+ ev = JSON.parse(line);
475
+ } catch {
476
+ continue; // partial trailing line, etc.
477
+ }
478
+ switch (ev.type) {
479
+ case "turn_start":
480
+ p.turns++;
481
+ break;
482
+ case "tool_execution_start": {
483
+ const name = String(ev.toolName ?? "tool");
484
+ p.toolCalls.push({
485
+ name,
486
+ summary: summarizeToolCall(name, (ev.args as Record<string, unknown>) ?? {}),
487
+ });
488
+ break;
489
+ }
490
+ case "message_end": {
491
+ const msg = ev.message as
492
+ | { role?: string; content?: { type: string; text?: string }[]; usage?: { totalTokens?: number; cost?: { total?: number } } }
493
+ | undefined;
494
+ if (msg?.role === "assistant") {
495
+ const txt = (msg.content ?? [])
496
+ .filter((c) => c.type === "text" && c.text)
497
+ .map((c) => c.text)
498
+ .join("");
499
+ if (txt.trim()) p.finalText = txt; // keep the latest non-empty assistant text
500
+ if (msg.usage) {
501
+ p.tokens = msg.usage.totalTokens;
502
+ p.cost = msg.usage.cost?.total;
503
+ }
504
+ }
505
+ break;
506
+ }
507
+ case "agent_start":
508
+ p.agentStarts++;
509
+ break;
510
+ case "agent_end": {
511
+ p.agentEnds++;
512
+ const msgs = ev.messages as
513
+ | { role?: string; toolName?: string; content?: unknown }[]
514
+ | undefined;
515
+ p.lastEndWasProcessWait = isParkedToolResult(msgs?.[msgs.length - 1]);
516
+ break;
517
+ }
518
+ case "response":
519
+ // RPC command failures (bad model, no API key, …) surface here.
520
+ if (ev.success === false) {
521
+ p.errorMsg = String(ev.error ?? `rpc ${ev.command ?? "command"} failed`);
522
+ }
523
+ break;
524
+ case "error":
525
+ p.errorMsg = String(ev.message ?? ev.error ?? line);
526
+ break;
527
+ }
528
+ }
529
+ p.running = p.agentStarts > p.agentEnds;
530
+ p.waitingOnProcess = !p.running && p.agentEnds > 0 && p.lastEndWasProcessWait;
531
+ p.done = !p.running && p.agentEnds > 0 && !p.lastEndWasProcessWait;
532
+ return p;
533
+ }
534
+
535
+ // ---------------------------------------------------------------------------
536
+ // spawning — kind=process
537
+ // ---------------------------------------------------------------------------
538
+
539
+ // Friendly name → unique babysit session id (babysit ids allow [\w.-]).
540
+ async function uniqueSessionId(name: string): Promise<string> {
541
+ const base = name.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "") || "proc";
542
+ const taken = new Set((await listSessions()).sessions.map((s) => s.id));
543
+ if (!taken.has(base)) return base;
544
+ for (let i = 2; ; i++) {
545
+ if (!taken.has(`${base}-${i}`)) return `${base}-${i}`;
546
+ }
547
+ }
548
+
549
+ interface ProcOpts {
550
+ name?: string;
551
+ command: string;
552
+ cwd: string;
553
+ timeout?: string; // default: none — dev servers may run indefinitely
554
+ idleTimeout?: string;
555
+ pty: boolean;
556
+ }
557
+
558
+ async function spawnProcess(opts: ProcOpts): Promise<{ id: string } | { error: string }> {
559
+ const bsArgs = ["run", "-d", "--json", "--size", "120x40"];
560
+ if (!opts.pty) bsArgs.push("--no-tty");
561
+ if (opts.timeout && opts.timeout !== "none") bsArgs.push("--timeout", opts.timeout);
562
+ if (opts.idleTimeout && opts.idleTimeout !== "none")
563
+ bsArgs.push("--idle-timeout", opts.idleTimeout);
564
+ if (opts.name) bsArgs.push("--id", await uniqueSessionId(opts.name));
565
+ bsArgs.push("--", SHELL, "-c", opts.command);
566
+
567
+ const r = await bs(bsArgs, { cwd: opts.cwd });
568
+ if (r.code !== 0) {
569
+ return {
570
+ error:
571
+ r.stderr || r.stdout ||
572
+ `babysit run failed (exit ${r.code}, no output) — check that \`${BABYSIT_BIN}\` works and ${ROOT} is writable`,
573
+ };
574
+ }
575
+ let id: string;
576
+ try {
577
+ id = JSON.parse(r.stdout).id;
578
+ } catch {
579
+ return { error: `could not parse id from: ${r.stdout}` };
580
+ }
581
+ writeMeta(id, {
582
+ kind: "process",
583
+ name: opts.name ?? id,
584
+ command: opts.command,
585
+ notified: false,
586
+ startedAt: Date.now(),
587
+ });
588
+ return { id };
589
+ }
590
+
591
+ // ---------------------------------------------------------------------------
592
+ // spawning — kind=subagent
593
+ // ---------------------------------------------------------------------------
594
+
595
+ function writePromptTempFile(agentName: string, prompt: string): string {
596
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-babysit-"));
597
+ const safe = agentName.replace(/[^\w.-]+/g, "_");
598
+ const file = path.join(dir, `prompt-${safe}.md`);
599
+ fs.writeFileSync(file, prompt, "utf-8");
600
+ return file;
601
+ }
602
+
603
+ // RPC messages are injected into the subagent's PTY stdin via `babysit send`.
604
+ // A PTY input queue is tiny (~1KB canonical limit on macOS), so long messages
605
+ // get truncated/dropped, mangling the RPC JSON and silently breaking spawn.
606
+ // Anything over this budget is written to a file instead, and only a short
607
+ // "read this file" instruction travels through the PTY.
608
+ const PTY_SAFE_MESSAGE_BYTES = 600;
609
+
610
+ function deliverableMessage(kind: "task" | "steering message", text: string): string {
611
+ if (Buffer.byteLength(text, "utf-8") <= PTY_SAFE_MESSAGE_BYTES) return text;
612
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-babysit-msg-"));
613
+ const file = path.join(dir, "message.md");
614
+ fs.writeFileSync(file, text, "utf-8");
615
+ return `Your full ${kind} is in the file ${file} — read it with the Read tool FIRST, then carry it out exactly as written.`;
616
+ }
617
+
618
+ interface SubagentOpts {
619
+ agent?: AgentConfig;
620
+ task: string;
621
+ model?: string;
622
+ tools?: string[];
623
+ cwd: string;
624
+ // Idle-timeout is OFF by default: an RPC-mode pi is silent while it works,
625
+ // so idle detection would false-kill a busy subagent. The absolute timeout
626
+ // is the safety valve instead.
627
+ idleTimeout?: string;
628
+ timeout: string;
629
+ }
630
+
631
+ async function spawnSubagent(
632
+ opts: SubagentOpts,
633
+ ): Promise<{ id: string; model?: string } | { error: string }> {
634
+ // Long-lived RPC worker: the task is NOT passed as argv — it is injected
635
+ // below as an RPC `prompt` command, whose response we validate so spawn
636
+ // failures (bad model, missing API key) are loud instead of a silent exit=1.
637
+ const piArgs: string[] = ["--mode", "rpc", "--no-session"];
638
+ const model = opts.model ?? opts.agent?.model;
639
+ if (model) piArgs.push("--model", model);
640
+ const tools = opts.tools ?? opts.agent?.tools;
641
+ if (tools && tools.length > 0) piArgs.push("--tools", tools.join(","));
642
+ piArgs.push("--append-system-prompt", SUBAGENT_GUIDANCE);
643
+ if (opts.agent?.systemPrompt?.trim()) {
644
+ const f = writePromptTempFile(opts.agent.name, opts.agent.systemPrompt);
645
+ piArgs.push("--append-system-prompt", f);
646
+ }
647
+ // Self-reaper: a finished subagent exits after a short idle grace instead of
648
+ // lingering until the absolute --timeout. Cancelled by a follow-up task, and
649
+ // it never reaps a turn parked on a process-exit notification. See self-reap.ts.
650
+ piArgs.push("--extension", path.join(EXT_DIR, "self-reap.ts"));
651
+
652
+ // A real PTY is used (NOT --no-tty): it lets a human `attach` and fully
653
+ // take over the subagent.
654
+ const bsArgs = [
655
+ "run",
656
+ "-d",
657
+ "--json",
658
+ "--size",
659
+ "120x40",
660
+ "--timeout",
661
+ opts.timeout,
662
+ ];
663
+ // Pretty-print the JSONL stream for humans who `attach`; the recorded log
664
+ // stays raw so parseEvents/`babysit log` are unaffected.
665
+ if (VIEW_CMD.trim()) bsArgs.push("--view-cmd", VIEW_CMD);
666
+ if (opts.idleTimeout && opts.idleTimeout !== "none") {
667
+ bsArgs.push("--idle-timeout", opts.idleTimeout);
668
+ }
669
+ bsArgs.push("--", PI_BIN, ...piArgs);
670
+
671
+ const r = await bs(bsArgs, { cwd: opts.cwd });
672
+ if (r.code !== 0) {
673
+ return { error: r.stderr || r.stdout || `babysit run failed (exit ${r.code}, no output) — check that \`${BABYSIT_BIN}\` works and ${ROOT} is writable` };
674
+ }
675
+ let id: string;
676
+ try {
677
+ id = JSON.parse(r.stdout).id;
678
+ } catch {
679
+ return { error: `could not parse id from: ${r.stdout}` };
680
+ }
681
+
682
+ // Stamp the kind IMMEDIATELY (with notified:true) so that if validation
683
+ // below fails and we kill the session, the exit poller does NOT mistake it
684
+ // for an un-notified process and fire a spurious process-end notification.
685
+ // The success path overwrites this with the full task meta.
686
+ writeMeta(id, { kind: "subagent", task: opts.task, notified: true });
687
+
688
+ // Wait for pi to boot (first JSON event in the log), then inject the task.
689
+ await bs(["expect", "-s", id, "--timeout", "30s", '\\{"type"']);
690
+ const sent = await sendRpc(id, {
691
+ type: "prompt",
692
+ message: `Task: ${deliverableMessage("task", opts.task)}`,
693
+ });
694
+ if ("error" in sent) {
695
+ await bs(["kill", "-s", id]);
696
+ return { error: `could not send task to subagent ${id}: ${sent.error}` };
697
+ }
698
+ // Validate the prompt was ACCEPTED (this is where "No API key found for …"
699
+ // and similar config errors surface — fail the spawn loudly).
700
+ const resp = await rpcResponse(id, sent.offset, "prompt", "60s");
701
+ if (!resp.ok) {
702
+ await bs(["kill", "-s", id]);
703
+ return { error: `subagent ${id} rejected the task: ${resp.error}` };
704
+ }
705
+ // Report the RESOLVED model (a fuzzy pattern may match something unexpected;
706
+ // null means nothing resolved at all).
707
+ let resolvedModel: string | undefined;
708
+ const gs = await sendRpc(id, { type: "get_state" });
709
+ if (!("error" in gs)) {
710
+ const st = await rpcResponse(id, gs.offset, "get_state", "15s");
711
+ if (st.ok && st.data) {
712
+ const m = (st.data as { model?: { id?: string } | null }).model;
713
+ if (m === null) {
714
+ await bs(["kill", "-s", id]);
715
+ return {
716
+ error: `subagent ${id} has no usable model${opts.model ? ` (requested "${opts.model}")` : ""} — check the model name with \`pi --list-models\`.`,
717
+ };
718
+ }
719
+ resolvedModel = m?.id;
720
+ }
721
+ }
722
+
723
+ writeMeta(id, {
724
+ kind: "subagent",
725
+ task: opts.task,
726
+ promptOffset: sent.offset,
727
+ model: resolvedModel,
728
+ startedAt: Date.now(),
729
+ });
730
+ return { id, model: resolvedModel };
731
+ }
732
+
733
+ // ---------------------------------------------------------------------------
734
+ // widget (live counts)
735
+ // ---------------------------------------------------------------------------
736
+
737
+ // A subagent whose task is done stays alive as an idle RPC worker (for
738
+ // follow-ups), so "running" in babysit does NOT mean "working" — count
739
+ // processes / busy subagents / idle subagents separately.
740
+ function renderWidgetLines(procs: number, busy: number, idle: number): string[] {
741
+ if (procs === 0 && busy === 0 && idle === 0) return [];
742
+ const parts: string[] = [];
743
+ if (procs > 0) parts.push(`${procs} process${procs > 1 ? "es" : ""}`);
744
+ if (busy > 0) parts.push(`${busy} subagent${busy > 1 ? "s" : ""} working`);
745
+ if (idle > 0) parts.push(`${idle} idle`);
746
+ return [`\x1b[44;97m ${parts.join(" \u00b7 ")} \x1b[0m`];
747
+ }
748
+
749
+ // Classify a running subagent as busy or idle by analyzing the current task's
750
+ // log slice (same logic babysit_check uses).
751
+ async function isTaskDone(id: string): Promise<boolean> {
752
+ try {
753
+ const meta = readMeta(id);
754
+ const logArgs = ["log", "-s", id];
755
+ if (meta?.promptOffset) logArgs.push("--since", String(meta.promptOffset));
756
+ return parseEvents((await bs(logArgs)).stdout).done;
757
+ } catch {
758
+ return false; // when unsure, treat as busy — never hide a live worker
759
+ }
760
+ }
761
+
762
+ // ---------------------------------------------------------------------------
763
+ // human take-over hint (no tmux dependency)
764
+ // ---------------------------------------------------------------------------
765
+
766
+ // The command a human can run in their OWN terminal/pane to take over a live
767
+ // process interactively (detach with Ctrl-\ Ctrl-\). `/babysit` shows this as a
768
+ // hint alongside an inline snapshot instead of spawning a tmux window itself.
769
+ function attachCmd(id: string): string {
770
+ return `BABYSIT_DIR=${shq(ROOT)} ${shq(BABYSIT_BIN)} attach -s ${shq(id)}`;
771
+ }
772
+
773
+ // ---------------------------------------------------------------------------
774
+ // waiting
775
+ // ---------------------------------------------------------------------------
776
+
777
+ interface WaitOutcome {
778
+ id: string;
779
+ kind: "done" | "exited" | "timeout" | "interrupted";
780
+ ok: boolean;
781
+ text: string;
782
+ status?: BsSession | null;
783
+ progress?: Progress;
784
+ }
785
+
786
+ // Wait for ONE subagent's current task. Completion = an agent_end whose last
787
+ // message is NOT a parked babysit_run/process toolResult (that one only means
788
+ // "turn parked, waiting for a background process — pi resumes on its own").
789
+ // Loop: analyze the current-task log slice; done → report; still going →
790
+ // block on the next agent_end via `babysit expect` (race-free byte offsets).
791
+ async function waitForTask(
792
+ id: string,
793
+ limitMs: number | null,
794
+ signal?: AbortSignal,
795
+ ): Promise<WaitOutcome> {
796
+ const meta = readMeta(id);
797
+ const base = meta?.promptOffset ?? 0;
798
+ const t0 = Date.now();
799
+
800
+ for (;;) {
801
+ const lg = await bs(["log", "-s", id, "--since", String(base), "--json"]);
802
+ let text = "";
803
+ let cur = base;
804
+ try {
805
+ const j = JSON.parse(lg.stdout);
806
+ text = j.text ?? "";
807
+ cur = j.offset ?? base;
808
+ } catch {
809
+ text = lg.stdout;
810
+ }
811
+ const prog = parseEvents(text);
812
+ const st = await statusOf(id);
813
+
814
+ const stats =
815
+ `turns=${prog.turns} tools=${prog.toolCalls.length}` +
816
+ (prog.tokens != null ? ` ctx=${prog.tokens}` : "") +
817
+ (prog.cost != null ? ` $${prog.cost.toFixed(4)}` : "");
818
+
819
+ if (prog.done) {
820
+ const body = clip(
821
+ prog.finalText.trim() || prog.errorMsg || "(no answer text)",
822
+ ANSWER_MAX_BYTES,
823
+ );
824
+ const ok = prog.finalText.trim().length > 0 || !prog.errorMsg;
825
+ return {
826
+ id,
827
+ kind: "done",
828
+ ok,
829
+ text:
830
+ `Subagent ${id} finished its task (${stats}).\n` +
831
+ `Session stays alive — follow-up: babysit_send { id: "${id}" }, ` +
832
+ `or babysit_kill when done.\n\n${body}`,
833
+ status: st,
834
+ progress: prog,
835
+ };
836
+ }
837
+
838
+ if (!st || st.state !== "running") {
839
+ // Crash / timeout / external kill — make the cause visible.
840
+ const tail = clip((await bs(["log", "-s", id, "--tail", "20"])).stdout.trim());
841
+ const body = clip(
842
+ prog.finalText.trim() || prog.errorMsg || tail || "(no output)",
843
+ ANSWER_MAX_BYTES,
844
+ );
845
+ return {
846
+ id,
847
+ kind: "exited",
848
+ ok: false,
849
+ text:
850
+ `Subagent ${id} EXITED before completing the task ` +
851
+ `(state=${st?.state ?? "missing"}, exit_code=${st?.exit_code ?? "?"}, ${stats}).\n\n${body}`,
852
+ status: st,
853
+ progress: prog,
854
+ };
855
+ }
856
+
857
+ const timeoutOutcome = (): WaitOutcome => ({
858
+ id,
859
+ kind: "timeout",
860
+ ok: false,
861
+ text:
862
+ `⏱ wait timed out; subagent ${id} still ` +
863
+ `${prog.waitingOnProcess ? "waiting on a background process" : "working"} (${stats}).`,
864
+ status: st,
865
+ progress: prog,
866
+ });
867
+
868
+ // Still working — block on the next agent_end.
869
+ let expectTimeout = "0"; // indefinite
870
+ if (limitMs != null) {
871
+ const remaining = limitMs - (Date.now() - t0);
872
+ if (remaining <= 0) return timeoutOutcome();
873
+ expectTimeout = `${Math.ceil(remaining / 1000)}s`;
874
+ }
875
+ const e = await bs(
876
+ ["expect", "-s", id, "--since", String(cur), "--timeout", expectTimeout, '"type":"agent_end"'],
877
+ { signal },
878
+ );
879
+ if (signal?.aborted || e.code === 130) {
880
+ return {
881
+ id,
882
+ kind: "interrupted",
883
+ ok: false,
884
+ text: `wait for ${id} was interrupted.`,
885
+ status: st,
886
+ progress: prog,
887
+ };
888
+ }
889
+ if (e.code === 124) return timeoutOutcome();
890
+ // e.code !== 0 (session likely exited mid-wait) falls through to the
891
+ // next loop iteration, where the exited branch reports the cause.
892
+ if (e.code !== 0) {
893
+ await new Promise((res) => setTimeout(res, 500));
894
+ }
895
+ }
896
+ }
897
+
898
+ // Mark a process session as already-reported so the exit-notification poller
899
+ // doesn't send a duplicate message for something the agent just observed.
900
+ function suppressNotify(id: string): void {
901
+ const meta = readMeta(id);
902
+ if (meta && meta.kind === "process" && !meta.notified) {
903
+ meta.notified = true;
904
+ writeMeta(id, meta);
905
+ }
906
+ }
907
+
908
+ // Wait for a PROCESS session: either until a regex appears in its output
909
+ // (`expect` — e.g. "server listening") or until the process exits.
910
+ async function waitForExit(
911
+ id: string,
912
+ limitMs: number | null,
913
+ signal?: AbortSignal,
914
+ expectPattern?: string,
915
+ ): Promise<WaitOutcome> {
916
+ const t = limitMs != null ? `${Math.ceil(limitMs / 1000)}s` : "0";
917
+
918
+ if (expectPattern) {
919
+ const e = await bs(["expect", "-s", id, "--timeout", t, expectPattern], { signal });
920
+ if (signal?.aborted || e.code === 130) {
921
+ return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
922
+ }
923
+ if (e.code === 0) {
924
+ const tail = clip((await bs(["log", "-s", id, "--tail", "10"])).stdout.trim());
925
+ return {
926
+ id,
927
+ kind: "done",
928
+ ok: true,
929
+ text: `Pattern /${expectPattern}/ matched in ${id} output (process still running).\n\nRecent output:\n${tail}`,
930
+ };
931
+ }
932
+ const st0 = await statusOf(id);
933
+ if (e.code === 124 && st0?.state === "running") {
934
+ return {
935
+ id,
936
+ kind: "timeout",
937
+ ok: false,
938
+ text: `⏱ wait timed out; /${expectPattern}/ not seen in ${id} output yet (still running).`,
939
+ status: st0,
940
+ };
941
+ }
942
+ // fall through: session exited before the pattern appeared
943
+ } else {
944
+ const w = await bs(["wait", "-s", id, "--timeout", t], { signal });
945
+ if (signal?.aborted || w.code === 130) {
946
+ return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
947
+ }
948
+ if (w.code === 124) {
949
+ // 124 is ambiguous (timeout vs child exiting 124) — disambiguate.
950
+ const st0 = await statusOf(id);
951
+ if (st0?.state === "running") {
952
+ return {
953
+ id,
954
+ kind: "timeout",
955
+ ok: false,
956
+ text: `⏱ wait timed out; process ${id} is still running.`,
957
+ status: st0,
958
+ };
959
+ }
960
+ }
961
+ }
962
+
963
+ const st = await statusOf(id);
964
+ if (!st) {
965
+ return { id, kind: "exited", ok: false, text: `No such session: ${id}` };
966
+ }
967
+ suppressNotify(id); // the agent sees the exit here; don't notify again
968
+ const tail = clip((await bs(["log", "-s", id, "--tail", "20"])).stdout.trim());
969
+ const ok = st.exit_code === 0;
970
+ const meta = readMeta(id);
971
+ return {
972
+ id,
973
+ kind: "exited",
974
+ ok,
975
+ text:
976
+ `Process ${id}${meta?.command ? ` (${meta.command})` : ""} ` +
977
+ `${ok ? "completed successfully" : `exited with code ${st.exit_code ?? "?"}`}` +
978
+ `${expectPattern ? ` before /${expectPattern}/ appeared` : ""}.` +
979
+ (tail ? `\n\nLast output:\n${tail}` : ""),
980
+ status: st,
981
+ };
982
+ }
983
+
984
+ const waitFor = (
985
+ id: string,
986
+ limitMs: number | null,
987
+ signal?: AbortSignal,
988
+ expectPattern?: string,
989
+ ): Promise<WaitOutcome> =>
990
+ kindOf(id) === "subagent"
991
+ ? waitForTask(id, limitMs, signal)
992
+ : waitForExit(id, limitMs, signal, expectPattern);
993
+
994
+ // ---------------------------------------------------------------------------
995
+ // bash background-command blocker (carried over from pi-processes)
996
+ // ---------------------------------------------------------------------------
997
+
998
+ // Heuristic (no shell AST): catch `... &` backgrounding (not `&&`), nohup,
999
+ // setsid, and disown — those should go through babysit_run instead.
1000
+ function backgroundsItself(command: string): boolean {
1001
+ const stripped = command.replace(/#[^\n]*/g, "").trimEnd();
1002
+ if (/(^|[^&])&\s*$/.test(stripped)) return true;
1003
+ if (/(^|[;&|]\s*)(nohup|setsid)\s/.test(stripped)) return true;
1004
+ if (/\bdisown\b/.test(stripped)) return true;
1005
+ return false;
1006
+ }
1007
+
1008
+ // ---------------------------------------------------------------------------
1009
+ // extension
1010
+ // ---------------------------------------------------------------------------
1011
+
1012
+ export default function (pi: ExtensionAPI) {
1013
+ let pollTimer: ReturnType<typeof setInterval> | undefined;
1014
+
1015
+ // Exit notifications for kind=process sessions: the poller detects
1016
+ // running→exited transitions and injects ONE message (triggerTurn) so an
1017
+ // agent that ended its turn after babysit_run is resumed automatically —
1018
+ // the old `process` tool contract. Kills via babysit_kill and exits already
1019
+ // reported by babysit_wait are suppressed via meta.notified.
1020
+ async function notifyEndedProcesses(): Promise<void> {
1021
+ const { sessions } = await listSessions();
1022
+ for (const s of sessions) {
1023
+ if (s.state === "running") continue;
1024
+ const meta = readMeta(s.id);
1025
+ if (!meta || meta.kind !== "process" || meta.notified) continue;
1026
+ meta.notified = true;
1027
+ writeMeta(s.id, meta);
1028
+ const tail = clip((await bs(["log", "-s", s.id, "--tail", "20"])).stdout.trim());
1029
+ const ok = s.exit_code === 0;
1030
+ const runtime = meta.startedAt
1031
+ ? `${Math.round((Date.now() - meta.startedAt) / 1000)}s`
1032
+ : "?";
1033
+ const summary = ok
1034
+ ? `Process "${s.id}" completed successfully after ${runtime}.`
1035
+ : s.state === "dead" || s.exit_code == null
1036
+ ? `Process "${s.id}" was terminated after ${runtime}.`
1037
+ : `Process "${s.id}" exited with code ${s.exit_code} after ${runtime}.`;
1038
+ pi.sendMessage(
1039
+ {
1040
+ customType: "pi-babysit-process-end",
1041
+ content:
1042
+ `${summary}\nCommand: ${meta.command ?? "?"}` +
1043
+ (tail ? `\n\nRecent output:\n${tail}` : "") +
1044
+ `\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.`,
1045
+ display: true,
1046
+ details: { id: s.id, exitCode: s.exit_code, success: ok, runtime },
1047
+ },
1048
+ { triggerTurn: true, deliverAs: "steer" },
1049
+ );
1050
+ }
1051
+ }
1052
+
1053
+ const refreshWidget = async (ctx: ExtensionContext) => {
1054
+ if (!ctx.hasUI) return;
1055
+ const active = (await listSessions()).sessions.filter((s) => s.state === "running");
1056
+ const subs = active.filter((s) => kindOf(s.id) === "subagent");
1057
+ const procs = active.length - subs.length;
1058
+ const doneFlags = await Promise.all(subs.map((s) => isTaskDone(s.id)));
1059
+ const idle = doneFlags.filter(Boolean).length;
1060
+ const lines = renderWidgetLines(procs, subs.length - idle, idle);
1061
+ // Per-session elapsed time (live: refreshed every poll tick).
1062
+ const doneById = new Map(subs.map((s, i) => [s.id, doneFlags[i]]));
1063
+ for (const s of active) {
1064
+ const isSub = kindOf(s.id) === "subagent";
1065
+ const tag = isSub ? (doneById.get(s.id) ? "sub idle" : "sub") : "proc";
1066
+ const el = elapsedOf(s.id);
1067
+ lines.push(` ⏳ ${s.id} [${tag}]${el ? ` ${el}` : ""}`);
1068
+ }
1069
+ ctx.ui.setWidget("pi-babysit", lines, { placement: "belowEditor" });
1070
+ };
1071
+
1072
+ // Render a subagent's final answer INLINE in the transcript as formatted
1073
+ // markdown (agent output is markdown). Fed via pi.sendMessage below.
1074
+ pi.registerMessageRenderer("pi-babysit-result", (message, _opts, theme) => {
1075
+ const d = (message.details ?? {}) as { title?: string; body?: string };
1076
+ const body =
1077
+ d.body ?? (typeof message.content === "string" ? message.content : "");
1078
+ const box = new Box(1, 0, (t) => theme.bg("customMessageBg", t));
1079
+ if (d.title) box.addChild(new Text(theme.fg("accent", d.title), 0, 0));
1080
+ box.addChild(new Markdown(body, 0, 0, getMarkdownTheme()));
1081
+ return box;
1082
+ });
1083
+
1084
+ // Process-end notification rendering (plain text in a subtle box).
1085
+ pi.registerMessageRenderer("pi-babysit-process-end", (message, _opts, theme) => {
1086
+ const content = typeof message.content === "string" ? message.content : "";
1087
+ const box = new Box(1, 0, (t) => theme.bg("customMessageBg", t));
1088
+ box.addChild(new Text(content, 0, 0));
1089
+ return box;
1090
+ });
1091
+
1092
+ let polling = false;
1093
+ pi.on("session_start", async (_event, ctx) => {
1094
+ // Session-local registry: scope the babysit root to this pi session so
1095
+ // other sessions' processes/subagents are invisible here. Resuming a
1096
+ // session keeps the same id, so its sessions come back with it.
1097
+ try {
1098
+ ROOT = path.join(ROOT_BASE, ctx.sessionManager.getSessionId());
1099
+ } catch {
1100
+ ROOT = ROOT_BASE;
1101
+ }
1102
+ // Warn early if the binary is missing so the user isn't surprised only when
1103
+ // a tool later fails. Tools/commands still enforce it via requireBabysit.
1104
+ if (ctx.hasUI && !(await babysitAvailable())) ctx.ui.notify(INSTALL_HINT, "warn");
1105
+ if (pollTimer) clearInterval(pollTimer);
1106
+ pollTimer = setInterval(() => {
1107
+ // Skip if the previous (async) poll hasn't finished, so slow babysit
1108
+ // calls never stack up.
1109
+ if (polling) return;
1110
+ polling = true;
1111
+ Promise.all([notifyEndedProcesses(), refreshWidget(ctx)])
1112
+ .catch(() => {
1113
+ /* ignore poll errors */
1114
+ })
1115
+ .finally(() => {
1116
+ polling = false;
1117
+ });
1118
+ }, POLL_MS);
1119
+ });
1120
+
1121
+ pi.on("session_shutdown", async () => {
1122
+ if (pollTimer) clearInterval(pollTimer);
1123
+ pollTimer = undefined;
1124
+ });
1125
+
1126
+ // Block bash commands that background themselves — they belong in
1127
+ // babysit_run (which supervises, logs, and notifies on exit).
1128
+ pi.on("tool_call", async (event) => {
1129
+ if (event.toolName !== "bash") return;
1130
+ const command = String((event.input as { command?: unknown }).command ?? "");
1131
+ if (!backgroundsItself(command)) return;
1132
+ return {
1133
+ block: true,
1134
+ reason:
1135
+ `This bash command tries to run in the background. Use babysit_run instead, e.g. ` +
1136
+ `babysit_run({ name: "background-process", command: ${JSON.stringify(command.replace(/\s*&\s*$/, ""))} })`,
1137
+ };
1138
+ });
1139
+
1140
+ // ----- babysit_run --------------------------------------------------------
1141
+ pi.registerTool({
1142
+ name: "babysit_run",
1143
+ label: "Babysit: run",
1144
+ description:
1145
+ "Start a supervised background session under babysit (NON-BLOCKING; returns a session id). " +
1146
+ "Two modes: (1) `command` — run any long-lived or slow command (build, tests, dev server, " +
1147
+ "watcher, interactive TUI) in a PTY; you get an AUTOMATIC notification when it exits, and " +
1148
+ "you can type into it with babysit_send and read its screen with babysit_check. " +
1149
+ "(2) `profile: \"subagent\"` + `task` — spawn a pi subagent that works on the task in the " +
1150
+ "background; poll with babysit_check, steer with babysit_send, block with babysit_wait, " +
1151
+ "stop with babysit_kill.",
1152
+ promptSnippet:
1153
+ "Run a command or a pi subagent as a supervised background session (non-blocking); returns a session id",
1154
+ promptGuidelines: [
1155
+ "Run every long-lived or slow command through babysit_run instead of bash: builds, test suites, dev servers, watchers, `tail -f`, anything expected to take more than a few seconds. Give it a clear stable `name` (e.g. cargo-build).",
1156
+ "babysit_run { command } ENDS YOUR TURN by default — you are resumed by the automatic process-end notification, so just stop; 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').",
1157
+ "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 }.",
1158
+ "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.",
1159
+ "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.",
1160
+ ],
1161
+ parameters: Type.Object({
1162
+ command: Type.Optional(
1163
+ Type.String({
1164
+ description: "Shell command to run (process mode). Mutually exclusive with profile/task.",
1165
+ }),
1166
+ ),
1167
+ name: Type.Optional(
1168
+ Type.String({
1169
+ description: "Friendly stable name for a process (becomes the session id), e.g. 'cargo-build'.",
1170
+ }),
1171
+ ),
1172
+ profile: Type.Optional(
1173
+ StringEnum(["subagent"] as const, {
1174
+ description: "Session profile. 'subagent' spawns a pi worker; requires `task`.",
1175
+ }),
1176
+ ),
1177
+ task: Type.Optional(
1178
+ Type.String({ description: "The task for the subagent to perform (subagent profile)." }),
1179
+ ),
1180
+ agent: Type.Optional(
1181
+ Type.String({ description: "Named agent definition (see ~/.pi/agent/agents). Subagent profile only." }),
1182
+ ),
1183
+ model: Type.Optional(Type.String({ description: "Model override for the subagent, e.g. 'sonnet'." })),
1184
+ tools: Type.Optional(
1185
+ Type.Array(Type.String(), { description: "Tool allowlist for the subagent." }),
1186
+ ),
1187
+ agentScope: Type.Optional(
1188
+ StringEnum(["user", "project", "both"] as const, {
1189
+ description: "Where to discover named agents. Default 'user'.",
1190
+ }),
1191
+ ),
1192
+ timeout: Type.Optional(
1193
+ Type.String({
1194
+ description:
1195
+ "Absolute auto-kill after this long (e.g. 30m). Default: none for processes (dev servers may run forever), 15m for subagents. 'none' disables.",
1196
+ }),
1197
+ ),
1198
+ idleTimeout: Type.Optional(
1199
+ Type.String({
1200
+ description:
1201
+ "Auto-kill after NO output for this long (e.g. 90s). Off by default — silence is often legitimate (a busy subagent, a quiet server). Set it only for commands that stream steadily.",
1202
+ }),
1203
+ ),
1204
+ pty: Type.Optional(
1205
+ Type.Boolean({
1206
+ description:
1207
+ "Process mode: run in a PTY (default true; enables interactive input/screen). false = plain pipes for cleaner line-oriented logs.",
1208
+ }),
1209
+ ),
1210
+ continueAfterStart: Type.Optional(
1211
+ Type.Boolean({
1212
+ description:
1213
+ "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.",
1214
+ }),
1215
+ ),
1216
+ }),
1217
+ async execute(_id, params, _signal, _onUpdate, ctx) {
1218
+ await requireBabysit();
1219
+ const isSubagent = params.profile === "subagent";
1220
+ if (isSubagent && !params.task) {
1221
+ return {
1222
+ content: [{ type: "text", text: "profile 'subagent' requires `task`." }],
1223
+ isError: true,
1224
+ details: {},
1225
+ };
1226
+ }
1227
+ if (!isSubagent && !params.command) {
1228
+ return {
1229
+ content: [{ type: "text", text: "Provide `command` (process) or profile 'subagent' + `task`." }],
1230
+ isError: true,
1231
+ details: {},
1232
+ };
1233
+ }
1234
+ if (params.command && isSubagent) {
1235
+ return {
1236
+ content: [{ type: "text", text: "`command` and profile 'subagent' are mutually exclusive." }],
1237
+ isError: true,
1238
+ details: {},
1239
+ };
1240
+ }
1241
+
1242
+ // --- process mode ---
1243
+ if (!isSubagent) {
1244
+ const res = await spawnProcess({
1245
+ name: params.name,
1246
+ command: params.command as string,
1247
+ cwd: ctx.cwd,
1248
+ timeout: params.timeout,
1249
+ idleTimeout: params.idleTimeout,
1250
+ pty: params.pty ?? true,
1251
+ });
1252
+ if ("error" in res) {
1253
+ return {
1254
+ content: [{ type: "text", text: `Failed to start process: ${res.error}` }],
1255
+ isError: true,
1256
+ details: {},
1257
+ };
1258
+ }
1259
+ await refreshWidget(ctx);
1260
+ const continueAfter = params.continueAfterStart === true;
1261
+ const nextStep = continueAfter
1262
+ ? "Continue with specific non-polling work now; the exit notification will arrive on its own."
1263
+ : "This turn will stop now so you can wait for the automatic process-end notification. Do not call babysit_check just to see whether it is still running.";
1264
+ return {
1265
+ content: [
1266
+ {
1267
+ type: "text",
1268
+ text:
1269
+ `Process started (id: ${res.id}). ${NOTIFY_MARKER}\n${nextStep}\n` +
1270
+ `Inspect: babysit_check { id: "${res.id}" } (screen: true for TUIs) · ` +
1271
+ `Wait: babysit_wait { id: "${res.id}" } · Kill: babysit_kill { id: "${res.id}" }\n` +
1272
+ `Human can watch/take over: /babysit`,
1273
+ },
1274
+ ],
1275
+ details: { id: res.id, kind: "process", command: params.command },
1276
+ // Same contract as the old `process` tool: end the turn so the
1277
+ // exit notification can resume the agent. This also makes the
1278
+ // parked-turn rule deterministic — the toolResult (with
1279
+ // NOTIFY_MARKER) is guaranteed to be the turn's LAST message.
1280
+ terminate: !continueAfter,
1281
+ };
1282
+ }
1283
+
1284
+ // --- subagent mode ---
1285
+ let agent: AgentConfig | undefined;
1286
+ if (params.agent) {
1287
+ const scope = (params.agentScope ?? "user") as AgentScope;
1288
+ const { agents } = discoverAgents(ctx.cwd, scope);
1289
+ agent = agents.find((a) => a.name === params.agent);
1290
+ if (!agent) {
1291
+ const avail = agents.map((a) => a.name).join(", ") || "none";
1292
+ return {
1293
+ content: [
1294
+ { type: "text", text: `Unknown agent "${params.agent}". Available: ${avail}.` },
1295
+ ],
1296
+ isError: true,
1297
+ details: {},
1298
+ };
1299
+ }
1300
+ }
1301
+
1302
+ const res = await spawnSubagent({
1303
+ agent,
1304
+ task: params.task as string,
1305
+ model: params.model,
1306
+ tools: params.tools,
1307
+ cwd: ctx.cwd,
1308
+ timeout: params.timeout ?? "15m",
1309
+ idleTimeout: params.idleTimeout,
1310
+ });
1311
+
1312
+ if ("error" in res) {
1313
+ return {
1314
+ content: [{ type: "text", text: `Failed to spawn subagent: ${res.error}` }],
1315
+ isError: true,
1316
+ details: {},
1317
+ };
1318
+ }
1319
+
1320
+ await refreshWidget(ctx);
1321
+ return {
1322
+ content: [
1323
+ {
1324
+ type: "text",
1325
+ text:
1326
+ `Subagent started (id: ${res.id})${agent ? ` [agent: ${agent.name}]` : ""}${res.model ? ` [model: ${res.model}]` : ""}.\n` +
1327
+ `Task accepted — running in the background; keep working (do NOT end your turn just to wait for it).\n` +
1328
+ `Poll: babysit_check { id: "${res.id}" }\n` +
1329
+ `Wait: babysit_wait { id: "${res.id}" }\n` +
1330
+ `Human can watch/steer: /babysit (pick ${res.id})`,
1331
+ },
1332
+ ],
1333
+ details: { id: res.id, kind: "subagent", agent: agent?.name, model: res.model, task: params.task },
1334
+ };
1335
+ },
1336
+ });
1337
+
1338
+ // ----- babysit_check ------------------------------------------------------
1339
+ pi.registerTool({
1340
+ name: "babysit_check",
1341
+ label: "Babysit: check",
1342
+ description:
1343
+ "Inspect babysit session(s). Without an id: lists all sessions (processes + subagents). " +
1344
+ "With an id: a process shows state + recent output (or the rendered screen with " +
1345
+ "`screen: true` — use for TUIs that redraw in place); a subagent shows live progress " +
1346
+ "(turns, recent tool calls, partial answer). Do NOT poll this while merely waiting for " +
1347
+ "a process to end — the exit notification is automatic.",
1348
+ promptSnippet: "Check status/progress of babysit sessions (processes and subagents)",
1349
+ parameters: Type.Object({
1350
+ id: Type.Optional(Type.String({ description: "Session id. Omit to list all sessions." })),
1351
+ tools: Type.Optional(
1352
+ Type.Number({ description: "Subagent: how many recent tool calls to show (default 8, max 50)." }),
1353
+ ),
1354
+ lines: Type.Optional(
1355
+ Type.Number({ description: "Process: how many log lines to show (default 30, max 200)." }),
1356
+ ),
1357
+ screen: Type.Optional(
1358
+ Type.Boolean({
1359
+ description:
1360
+ "Process: capture the rendered terminal screen instead of the log stream (for full-screen TUIs).",
1361
+ }),
1362
+ ),
1363
+ }),
1364
+ async execute(_id, params) {
1365
+ await requireBabysit();
1366
+ if (!params.id) {
1367
+ const { sessions, error } = await listSessions();
1368
+ if (error) {
1369
+ return {
1370
+ content: [{ type: "text", text: `Could not list sessions: ${error}` }],
1371
+ isError: true,
1372
+ details: {},
1373
+ };
1374
+ }
1375
+ if (sessions.length === 0) {
1376
+ return { content: [{ type: "text", text: "No babysit sessions." }], details: {} };
1377
+ }
1378
+ const lines = sessions.map((s) => {
1379
+ const meta = readMeta(s.id);
1380
+ const kind = meta?.kind ?? "process";
1381
+ const flag = s.note ? ` ⚑ ${s.note}` : "";
1382
+ const ec = s.exit_code != null ? ` exit=${s.exit_code}` : "";
1383
+ const what = (kind === "subagent" ? meta?.task : meta?.command) ?? "";
1384
+ const preview = what.length > 60 ? `${what.slice(0, 57)}…` : what;
1385
+ return `${s.id} [${kind}] ${s.state}${ec}${flag}${preview ? ` — ${preview}` : ""}`;
1386
+ });
1387
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { sessions } };
1388
+ }
1389
+
1390
+ const st = await statusOf(params.id);
1391
+ if (!st) {
1392
+ return {
1393
+ content: [{ type: "text", text: `No such session: ${params.id}` }],
1394
+ isError: true,
1395
+ details: {},
1396
+ };
1397
+ }
1398
+ const meta = readMeta(params.id);
1399
+
1400
+ // --- process ---
1401
+ if (meta?.kind !== "subagent") {
1402
+ const parts: string[] = [];
1403
+ let header = `[process] state=${st.state}`;
1404
+ if (st.state === "running") {
1405
+ const el = elapsedOf(params.id);
1406
+ if (el) header += ` elapsed=${el}`;
1407
+ }
1408
+ if (st.exit_code != null) header += ` exit_code=${st.exit_code}`;
1409
+ if (meta?.command) header += `\ncommand: ${meta.command}`;
1410
+ if (st.note) header += ` ⚑ ${st.note}`;
1411
+ parts.push(header);
1412
+ if (params.screen) {
1413
+ const sc = await bs(["screenshot", "-s", params.id, "--trim"]);
1414
+ parts.push(`--- screen ---\n${clip(sc.stdout.trimEnd()) || "(blank screen)"}`);
1415
+ } else {
1416
+ const nLines = Math.min(Math.max(1, params.lines ?? 30), 200);
1417
+ const tail = clip(
1418
+ (await bs(["log", "-s", params.id, "--tail", String(nLines)])).stdout.trimEnd(),
1419
+ );
1420
+ parts.push(tail ? `--- recent output ---\n${tail}` : "(no output yet)");
1421
+ }
1422
+ return {
1423
+ content: [{ type: "text", text: parts.join("\n") }],
1424
+ details: { status: st, kind: "process" },
1425
+ };
1426
+ }
1427
+
1428
+ // --- subagent: analyze only the current task's slice ---
1429
+ const logArgs = ["log", "-s", params.id];
1430
+ if (meta?.promptOffset) logArgs.push("--since", String(meta.promptOffset));
1431
+ const prog = parseEvents((await bs(logArgs)).stdout);
1432
+ const nTools = Math.min(Math.max(1, params.tools ?? 8), 50);
1433
+ const recent = prog.toolCalls.slice(-nTools);
1434
+
1435
+ const parts: string[] = [];
1436
+ let header = `[subagent] state=${st.state}`;
1437
+ if (st.state === "running") {
1438
+ const el = elapsedOf(params.id);
1439
+ if (el) header += ` elapsed=${el}`;
1440
+ header += prog.done
1441
+ ? " · task-complete (idle — follow-up via babysit_send, or babysit_kill)"
1442
+ : prog.waitingOnProcess
1443
+ ? " · waiting-on-background-process"
1444
+ : " · working";
1445
+ }
1446
+ if (st.exit_code != null) header += ` exit_code=${st.exit_code}`;
1447
+ header += ` turns=${prog.turns} tools=${prog.toolCalls.length}`;
1448
+ if (prog.tokens != null) header += ` ctx=${prog.tokens}`;
1449
+ if (prog.cost != null) header += ` $${prog.cost.toFixed(4)}`;
1450
+ if (st.note) header += ` ⚑ ${st.note}`;
1451
+ parts.push(header);
1452
+
1453
+ if (prog.errorMsg) parts.push(`⚠ error: ${clip(prog.errorMsg, ANSWER_MAX_BYTES)}`);
1454
+
1455
+ if (recent.length > 0) {
1456
+ const skipped = prog.toolCalls.length - recent.length;
1457
+ parts.push(
1458
+ `--- recent tool calls${skipped > 0 ? ` (+${skipped} earlier)` : ""} ---\n` +
1459
+ recent.map((t) => ` ${t.summary}`).join("\n"),
1460
+ );
1461
+ }
1462
+
1463
+ if (prog.finalText.trim()) {
1464
+ parts.push(`--- answer so far ---\n${clip(prog.finalText.trim(), ANSWER_MAX_BYTES)}`);
1465
+ } else if (prog.toolCalls.length === 0 && st.state !== "running") {
1466
+ // Died before doing anything — show the log tail so the cause of
1467
+ // death (model error, crash, timeout) is visible, not hidden.
1468
+ const tail = clip((await bs(["log", "-s", params.id, "--tail", "15"])).stdout.trim());
1469
+ parts.push(tail ? `--- last output ---\n${tail}` : "(no output)");
1470
+ } else if (prog.toolCalls.length === 0) {
1471
+ parts.push("(starting up… no events yet)");
1472
+ } else {
1473
+ parts.push("(working… no answer text yet)");
1474
+ }
1475
+
1476
+ return {
1477
+ content: [{ type: "text", text: parts.join("\n") }],
1478
+ details: { status: st, progress: prog, kind: "subagent" },
1479
+ };
1480
+ },
1481
+ });
1482
+
1483
+ // ----- babysit_send -------------------------------------------------------
1484
+ pi.registerTool({
1485
+ name: "babysit_send",
1486
+ label: "Babysit: send",
1487
+ description:
1488
+ "Send input to a babysit session. Process: `text` types a line into its stdin (PTY), " +
1489
+ "`keys` presses named keys (Enter, Tab, Esc, Up/Down/Left/Right, C-c, F1…) — use with " +
1490
+ "babysit_check { screen: true } to drive interactive programs. Subagent: `text` is " +
1491
+ "STEERING while it works, or a NEW TASK when it is idle (mode: auto/steer/task) — this " +
1492
+ "is how you resume a finished subagent with full context.",
1493
+ promptSnippet: "Send text/keys to a process, or steering/follow-up tasks to a subagent",
1494
+ parameters: Type.Object({
1495
+ id: Type.String({ description: "Session id." }),
1496
+ text: Type.Optional(
1497
+ Type.String({ description: "Text to send (a line for processes; a message for subagents)." }),
1498
+ ),
1499
+ keys: Type.Optional(
1500
+ Type.Array(Type.String(), {
1501
+ description: "Process only: named keys pressed in order (e.g. ['Down','Down','Enter'], ['C-c']).",
1502
+ }),
1503
+ ),
1504
+ mode: Type.Optional(
1505
+ StringEnum(["auto", "steer", "task"] as const, {
1506
+ description:
1507
+ "Subagent only. auto (default): steer if mid-run, otherwise start a new task. steer/task force one behavior.",
1508
+ }),
1509
+ ),
1510
+ noNewline: Type.Optional(
1511
+ Type.Boolean({ description: "Process only: don't append a trailing newline to `text`." }),
1512
+ ),
1513
+ }),
1514
+ async execute(_id, params) {
1515
+ await requireBabysit();
1516
+ const st = await statusOf(params.id);
1517
+ if (!st || st.state !== "running") {
1518
+ return {
1519
+ content: [
1520
+ { type: "text", text: `Session ${params.id} is not running (${st?.state ?? "missing"}).` },
1521
+ ],
1522
+ isError: true,
1523
+ details: {},
1524
+ };
1525
+ }
1526
+ const meta = readMeta(params.id);
1527
+
1528
+ // --- process: raw text/keys into the PTY ---
1529
+ if (meta?.kind !== "subagent") {
1530
+ if (!params.text && !params.keys?.length) {
1531
+ return {
1532
+ content: [{ type: "text", text: "Provide `text` or `keys`." }],
1533
+ isError: true,
1534
+ details: {},
1535
+ };
1536
+ }
1537
+ if (params.keys?.length) {
1538
+ const r = await bs(["key", "-s", params.id, ...params.keys]);
1539
+ if (r.code !== 0) {
1540
+ return {
1541
+ content: [{ type: "text", text: r.stderr || "key send failed" }],
1542
+ isError: true,
1543
+ details: {},
1544
+ };
1545
+ }
1546
+ }
1547
+ if (params.text != null) {
1548
+ const args = ["send", "-s", params.id];
1549
+ if (params.noNewline) args.push("--no-newline");
1550
+ args.push(params.text);
1551
+ const r = await bs(args);
1552
+ if (r.code !== 0) {
1553
+ return {
1554
+ content: [{ type: "text", text: r.stderr || "send failed" }],
1555
+ isError: true,
1556
+ details: {},
1557
+ };
1558
+ }
1559
+ }
1560
+ return {
1561
+ content: [
1562
+ {
1563
+ type: "text",
1564
+ text: `Sent to ${params.id}. Read the reaction with babysit_check { id: "${params.id}"${params.keys?.length ? ", screen: true" : ""} } — don't expect the echo of your own input.`,
1565
+ },
1566
+ ],
1567
+ details: { kind: "process" },
1568
+ };
1569
+ }
1570
+
1571
+ // --- subagent: steer / follow-up task over RPC ---
1572
+ if (!params.text) {
1573
+ return {
1574
+ content: [{ type: "text", text: "Provide `text` (steering or follow-up task)." }],
1575
+ isError: true,
1576
+ details: {},
1577
+ };
1578
+ }
1579
+ let mode = params.mode ?? "auto";
1580
+ if (mode === "auto") {
1581
+ // isStreaming tells us whether an agent run is in flight right now.
1582
+ const gs = await sendRpc(params.id, { type: "get_state" });
1583
+ let streaming = true; // assume busy when unsure — steering is the safe default
1584
+ if (!("error" in gs)) {
1585
+ const r = await rpcResponse(params.id, gs.offset, "get_state", "10s");
1586
+ if (r.ok) streaming = Boolean((r.data as { isStreaming?: boolean })?.isStreaming);
1587
+ }
1588
+ mode = streaming ? "steer" : "task";
1589
+ }
1590
+ const cmd =
1591
+ mode === "steer"
1592
+ ? { type: "steer", message: deliverableMessage("steering message", params.text) }
1593
+ : { type: "prompt", message: deliverableMessage("task", params.text) };
1594
+ const sent = await sendRpc(params.id, cmd);
1595
+ if ("error" in sent) {
1596
+ return {
1597
+ content: [{ type: "text", text: sent.error }],
1598
+ isError: true,
1599
+ details: {},
1600
+ };
1601
+ }
1602
+ const resp = await rpcResponse(params.id, sent.offset, cmd.type, "15s");
1603
+ if (!resp.ok) {
1604
+ return {
1605
+ content: [{ type: "text", text: `${cmd.type} was not accepted: ${resp.error}` }],
1606
+ isError: true,
1607
+ details: { mode },
1608
+ };
1609
+ }
1610
+ if (mode === "task") {
1611
+ // New task → new bookkeeping window, so check/wait track THIS task.
1612
+ writeMeta(params.id, {
1613
+ kind: "subagent",
1614
+ task: params.text,
1615
+ promptOffset: sent.offset,
1616
+ model: meta?.model,
1617
+ });
1618
+ }
1619
+ return {
1620
+ content: [
1621
+ {
1622
+ type: "text",
1623
+ text:
1624
+ mode === "steer"
1625
+ ? `Steering queued for ${params.id} (delivered between turns).`
1626
+ : `New task started on ${params.id} — wait for it with babysit_wait.`,
1627
+ },
1628
+ ],
1629
+ details: { mode, kind: "subagent" },
1630
+ };
1631
+ },
1632
+ });
1633
+
1634
+ // ----- babysit_wait -------------------------------------------------------
1635
+ pi.registerTool({
1636
+ name: "babysit_wait",
1637
+ label: "Babysit: wait",
1638
+ description:
1639
+ "Block until babysit session(s) finish, then return the result. A process finishes " +
1640
+ "when it EXITS (or, with `expect`, as soon as a regex appears in its output — e.g. wait " +
1641
+ "for 'listening on' before hitting a dev server). A subagent finishes when its current " +
1642
+ "TASK completes (the session stays alive for follow-ups). Pass `id` for one session, or " +
1643
+ "`ids` + `mode`: 'all' (default) waits for every one, 'any' returns on the FIRST finisher. " +
1644
+ "Prefer ending your turn over babysit_wait when a process result is not needed this turn — " +
1645
+ "the exit notification will resume you.",
1646
+ promptSnippet: "Block until session(s) finish — process exit / output pattern / subagent task done",
1647
+ parameters: Type.Object({
1648
+ id: Type.Optional(Type.String({ description: "Session id (single wait)." })),
1649
+ ids: Type.Optional(
1650
+ Type.Array(Type.String(), {
1651
+ description: "Session ids for a multi-wait (use with mode).",
1652
+ }),
1653
+ ),
1654
+ mode: Type.Optional(
1655
+ StringEnum(["all", "any"] as const, {
1656
+ description: "Multi-wait mode: 'all' (default) or 'any' (first to finish wins).",
1657
+ }),
1658
+ ),
1659
+ timeout: Type.Optional(
1660
+ Type.String({ description: "Give up after this long (e.g. 5m). Default: wait indefinitely." }),
1661
+ ),
1662
+ expect: Type.Optional(
1663
+ Type.String({
1664
+ description:
1665
+ "Process only: return as soon as this regex appears in the output (readiness marker) instead of waiting for exit.",
1666
+ }),
1667
+ ),
1668
+ }),
1669
+ async execute(_id, params, signal) {
1670
+ await requireBabysit();
1671
+ const ids = params.ids?.length ? params.ids : params.id ? [params.id] : [];
1672
+ if (ids.length === 0) {
1673
+ return {
1674
+ content: [{ type: "text", text: "Provide `id` or a non-empty `ids` array." }],
1675
+ isError: true,
1676
+ details: {},
1677
+ };
1678
+ }
1679
+ const limitMs = parseDurMs(params.timeout);
1680
+
1681
+ if (ids.length === 1) {
1682
+ const r = await waitFor(ids[0], limitMs, signal, params.expect);
1683
+ return {
1684
+ content: [{ type: "text", text: r.text }],
1685
+ isError: !r.ok,
1686
+ details: {
1687
+ status: r.status,
1688
+ progress: r.progress,
1689
+ timedOut: r.kind === "timeout",
1690
+ interrupted: r.kind === "interrupted",
1691
+ },
1692
+ };
1693
+ }
1694
+
1695
+ if ((params.mode ?? "all") === "all") {
1696
+ // Parallel waits; report every result in input order.
1697
+ const results = await Promise.all(
1698
+ ids.map((i) => waitFor(i, limitMs, signal, params.expect)),
1699
+ );
1700
+ const ok = results.every((r) => r.ok);
1701
+ return {
1702
+ content: [
1703
+ {
1704
+ type: "text",
1705
+ text: results.map((r) => `── ${r.id} [${r.kind}] ──\n${r.text}`).join("\n\n"),
1706
+ },
1707
+ ],
1708
+ isError: !ok,
1709
+ details: {
1710
+ results: results.map((r) => ({ id: r.id, kind: r.kind, ok: r.ok })),
1711
+ },
1712
+ };
1713
+ }
1714
+
1715
+ // mode "any": race the waits, then cancel the losers (their sessions keep
1716
+ // running — only OUR blocked `expect`/`wait` children are cancelled).
1717
+ const ctrl = new AbortController();
1718
+ const onOuterAbort = () => ctrl.abort();
1719
+ signal?.addEventListener("abort", onOuterAbort, { once: true });
1720
+ try {
1721
+ const first = await Promise.race(
1722
+ ids.map((i) => waitFor(i, limitMs, ctrl.signal, params.expect)),
1723
+ );
1724
+ const others = ids.filter((i) => i !== first.id);
1725
+ return {
1726
+ content: [
1727
+ {
1728
+ type: "text",
1729
+ text:
1730
+ `First to finish: ${first.id} [${first.kind}]` +
1731
+ (others.length ? ` — still waiting-able: ${others.join(", ")}` : "") +
1732
+ `\n\n${first.text}`,
1733
+ },
1734
+ ],
1735
+ isError: !first.ok,
1736
+ details: { first: { id: first.id, kind: first.kind, ok: first.ok }, remaining: others },
1737
+ };
1738
+ } finally {
1739
+ ctrl.abort();
1740
+ signal?.removeEventListener("abort", onOuterAbort);
1741
+ }
1742
+ },
1743
+ });
1744
+
1745
+ // ----- babysit_kill -------------------------------------------------------
1746
+ pi.registerTool({
1747
+ name: "babysit_kill",
1748
+ label: "Babysit: kill",
1749
+ description: "Terminate a babysit session (process or subagent).",
1750
+ promptSnippet: "Terminate a babysit session",
1751
+ parameters: Type.Object({ id: Type.String({ description: "Session id." }) }),
1752
+ async execute(_id, params, _signal, _onUpdate, ctx) {
1753
+ await requireBabysit();
1754
+ suppressNotify(params.id); // tool-initiated kill → no end notification
1755
+ const r = await bs(["kill", "-s", params.id, "--json"]);
1756
+ await refreshWidget(ctx);
1757
+ if (r.code !== 0) {
1758
+ return {
1759
+ content: [{ type: "text", text: r.stderr || "kill failed" }],
1760
+ isError: true,
1761
+ details: {},
1762
+ };
1763
+ }
1764
+ return { content: [{ type: "text", text: `Killed ${params.id}.` }], details: {} };
1765
+ },
1766
+ });
1767
+
1768
+ // ----- /babysit -----------------------------------------------------------
1769
+ // Arrow up/down picker over all sessions (like /stash). Renders an INLINE
1770
+ // snapshot (no tmux): running process → current rendered screen + recent
1771
+ // output + a copy-paste `babysit attach` take-over hint; running subagent →
1772
+ // read-only progress; finished → summary. Re-run to refresh.
1773
+ pi.registerCommand("babysit", {
1774
+ description: "Pick a babysit session (↑/↓) to snapshot/inspect",
1775
+ handler: async (_args, ctx) => {
1776
+ if (!(await babysitAvailable())) {
1777
+ ctx.ui.notify(INSTALL_HINT, "error");
1778
+ return;
1779
+ }
1780
+ const sessions = (await listSessions()).sessions.sort((a, b) =>
1781
+ a.state === b.state ? 0 : a.state === "running" ? -1 : 1,
1782
+ );
1783
+ if (sessions.length === 0) {
1784
+ ctx.ui.notify("No babysit sessions.", "info");
1785
+ return;
1786
+ }
1787
+
1788
+ const whatOf = (s: BsSession): string => {
1789
+ const meta = readMeta(s.id);
1790
+ return ((meta?.kind === "subagent" ? meta.task : meta?.command) ?? "")
1791
+ .replace(/\s+/g, " ")
1792
+ .trim();
1793
+ };
1794
+
1795
+ // Labels must be unique for index mapping; the id makes them unique.
1796
+ const labels = sessions.map((s) => {
1797
+ const kind = kindOf(s.id);
1798
+ const icon = s.state === "running" ? "⏳" : s.exit_code === 0 ? "✓" : "✗";
1799
+ const ec = s.exit_code != null ? ` exit=${s.exit_code}` : "";
1800
+ const flag = s.note ? " ⚑" : "";
1801
+ const what = whatOf(s);
1802
+ const preview = what.length > 60 ? `${what.slice(0, 57)}…` : what;
1803
+ return `${icon} ${s.id}${flag} [${kind}] ${s.state}${ec}${preview ? ` — ${preview}` : ""}`;
1804
+ });
1805
+
1806
+ const choice = await ctx.ui.select("Babysit sessions:", labels);
1807
+ if (!choice) return;
1808
+ const picked = sessions[labels.indexOf(choice)];
1809
+ if (!picked) return;
1810
+ const kind = kindOf(picked.id);
1811
+ const elapsed = picked.state === "running" ? elapsedOf(picked.id) : null;
1812
+ const elapsedSuffix = elapsed ? ` ${elapsed}` : "";
1813
+
1814
+ // Inline snapshot (running) or summary (finished) — no tmux window.
1815
+ if (kind === "subagent") {
1816
+ // Parse the RPC event stream and show the final answer, not raw JSONL.
1817
+ const prog = parseEvents((await bs(["log", "-s", picked.id])).stdout);
1818
+ const stats =
1819
+ `turns=${prog.turns} tools=${prog.toolCalls.length}` +
1820
+ (prog.tokens != null ? ` ctx=${prog.tokens}` : "") +
1821
+ (prog.cost != null ? ` $${prog.cost.toFixed(4)}` : "");
1822
+ const body =
1823
+ (prog.finalText.trim() ||
1824
+ prog.errorMsg ||
1825
+ (await bs(["log", "-s", picked.id, "--tail", "20"])).stdout.trim() ||
1826
+ "(no output)") +
1827
+ (picked.state === "running"
1828
+ ? "\n\n_Live subagent (read-only). Re-run `/babysit` to refresh this snapshot._"
1829
+ : "");
1830
+ const title =
1831
+ `${picked.id} ${picked.state}${elapsedSuffix}` +
1832
+ (picked.exit_code != null ? ` (exit=${picked.exit_code})` : "") +
1833
+ ` ${stats}`;
1834
+ if (ctx.hasUI) {
1835
+ pi.sendMessage({
1836
+ customType: "pi-babysit-result",
1837
+ content: title,
1838
+ display: true,
1839
+ details: { title, body },
1840
+ });
1841
+ } else {
1842
+ ctx.ui.notify(`${title}\n\n${body}`, "info");
1843
+ }
1844
+ } else {
1845
+ const meta = readMeta(picked.id);
1846
+ const running = picked.state === "running";
1847
+ // For a LIVE process show the CURRENT rendered screen (TUIs redraw in
1848
+ // place, so the raw stream isn't representative); for a finished one
1849
+ // the recorded tail is enough.
1850
+ const screen = running
1851
+ ? (await bs(["screenshot", "-s", picked.id, "--trim"])).stdout.trimEnd()
1852
+ : "";
1853
+ const tail = (await bs(["log", "-s", picked.id, "--tail", "30"])).stdout.trimEnd();
1854
+ const title =
1855
+ `${picked.id} ${picked.state}${elapsedSuffix}` +
1856
+ (picked.exit_code != null ? ` (exit=${picked.exit_code})` : "");
1857
+ const body =
1858
+ (meta?.command ? `\`${meta.command}\`\n\n` : "") +
1859
+ (screen ? `**screen**\n\`\`\`\n${screen}\n\`\`\`\n\n` : "") +
1860
+ (tail
1861
+ ? `**recent output**\n\`\`\`\n${tail}\n\`\`\``
1862
+ : screen
1863
+ ? ""
1864
+ : "(no output)") +
1865
+ (running
1866
+ ? `\n\n_Take over in your own terminal:_ \`${attachCmd(picked.id)}\` _(detach: Ctrl-\\ Ctrl-\\)._ Re-run \`/babysit\` to refresh this snapshot.`
1867
+ : "");
1868
+ if (ctx.hasUI) {
1869
+ pi.sendMessage({
1870
+ customType: "pi-babysit-result",
1871
+ content: title,
1872
+ display: true,
1873
+ details: { title, body },
1874
+ });
1875
+ } else {
1876
+ ctx.ui.notify(`${title}\n\n${body}`, "info");
1877
+ }
1878
+ }
1879
+ },
1880
+ });
1881
+ }