@yusukeshib/pi-babysit 0.6.4 → 0.6.6
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 +23 -4
- package/index.ts +341 -74
- package/package.json +1 -1
- package/rpc-stream-proxy.mjs +39 -6
- package/self-reap.ts +54 -17
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ programs** (installers, wizards, REPLs): type with `babysit_send`
|
|
|
56
56
|
|
|
57
57
|
| Tool | What it does |
|
|
58
58
|
| ---- | ------------ |
|
|
59
|
-
| `babysit_run` | Run any command (`command`, optional `name`/`pty`/`timeout`/`idleTimeout`/`retryOnWorkerDeath`/`notificationGroup`). Set `foreground: true` for one process or subagent whose result is needed in the same tool call; use `returnPattern`/`returnLines`/`maxBytes` to keep noisy process output bounded. Or start a named background subagent (`profile: "subagent"`, `task`, optional `name`/`agent`/`model`/`tools`/`maxDepth` and budget fields), then always collect it with `babysit_wait`. `maxDepth` defaults to 1. Subagent `continueAfterStart: true` is accepted as a compatibility alias for this default background behavior. Quick commands return inline; longer process runs notify in the background |
|
|
59
|
+
| `babysit_run` | Run any command (`command`, optional `name`/`pty`/`timeout`/`idleTimeout`/`retryOnWorkerDeath`/`notificationGroup`/`lifecycle`). Set `foreground: true` for one process or subagent whose result is needed in the same tool call; use `returnPattern`/`returnLines`/`maxBytes` to keep noisy process output bounded. Foreground and non-interactive process waits default to lifecycle `attached`, which terminates the process tree if that owning tool call is interrupted; background starts default to `detached`. Or start a named background subagent (`profile: "subagent"`, `task`, optional `name`/`agent`/`model`/`tools`/`maxDepth` and budget fields), then always collect it with `babysit_wait`. `maxDepth` defaults to 1. Subagent `continueAfterStart: true` is accepted as a compatibility alias for this default background behavior. Quick commands return inline; longer process runs notify in the background |
|
|
60
60
|
| `babysit_check` | Without an id, list sessions with state/kind filters. With an id, inspect bounded output, search with `pattern`, or capture a TUI with `screen: true`; `maxBytes` overrides the 4 KB default up to 24 KB |
|
|
61
61
|
| `babysit_send` | Process: type `text` / press `keys` into the PTY. Subagent: steer mid-run, or send a follow-up task when confirmed settled (`mode: auto/steer/task`); explicit task mode rejects busy, parked, or unknown state |
|
|
62
62
|
| `babysit_wait` | Block until done: process exit (or `expect: "regex"` readiness marker), subagent task completion. Multi-wait: up to 32 unique `ids` + `mode: "any"\|"all"` |
|
|
@@ -80,8 +80,14 @@ backgrounding (`… &`, `nohup`, `setsid`, `disown`). Set
|
|
|
80
80
|
|
|
81
81
|
A widget below the editor separates session **kind** from task **state** at a glance:
|
|
82
82
|
summary counts use `RUNNING` / `IDLE`, and every row is labeled
|
|
83
|
-
`PROCESS RUNNING`, `AGENT RUNNING`, or `AGENT IDLE`.
|
|
84
|
-
|
|
83
|
+
`PROCESS RUNNING`, `AGENT RUNNING`, or `AGENT IDLE`. In fullscreen mode, click
|
|
84
|
+
any live row to toggle between its latest line and latest 20 available output
|
|
85
|
+
lines. Finished sessions are behind a clickable `FINISHED` row; browse all
|
|
86
|
+
retained sessions five at a time and click a row to expand it, including failed
|
|
87
|
+
processes and agents. A session with no available output shows an explicit
|
|
88
|
+
placeholder when expanded. Mouse clicks require fullscreen mode; `/babysit`
|
|
89
|
+
works in regular mode. The displayed `age` is total session age, not time spent
|
|
90
|
+
in the current state. Transcript details
|
|
85
91
|
from tool results, snapshots, and completion notifications collapse or expand
|
|
86
92
|
with Pi's tool-output toggle (`Ctrl+O` by default).
|
|
87
93
|
|
|
@@ -132,7 +138,20 @@ suite at the end. Background subagents must likewise be collected before the
|
|
|
132
138
|
parent task finishes so their answer and nested usage are not lost.
|
|
133
139
|
Set `PI_BABYSIT_ALLOW_BASH=1` only as an explicit emergency escape hatch.
|
|
134
140
|
|
|
135
|
-
##
|
|
141
|
+
## Process ownership, interruption, shutdown, and reload
|
|
142
|
+
|
|
143
|
+
An inline process wait (`foreground: true`, or process execution in a non-UI
|
|
144
|
+
mode) defaults to `lifecycle: "attached"`. If its owning `babysit_run` tool call
|
|
145
|
+
is interrupted through Pi's AbortSignal, pi-babysit terminates the supervised
|
|
146
|
+
process tree through the babysit session API and verifies persisted terminal
|
|
147
|
+
state before returning. Set `lifecycle: "detached"` explicitly only when a
|
|
148
|
+
foreground wait may be interrupted but the process must continue. Interactive
|
|
149
|
+
background starts are detached; `lifecycle: "attached"` therefore requires
|
|
150
|
+
`foreground: true` in interactive mode.
|
|
151
|
+
|
|
152
|
+
An explicit `babysit_wait` is only a waiter, not the process owner. Interrupting
|
|
153
|
+
it never terminates the process; call `babysit_kill` when termination is wanted.
|
|
154
|
+
This cooperative policy does not claim to detect an abrupt host `SIGKILL`.
|
|
136
155
|
|
|
137
156
|
A real Pi quit terminates every running process and subagent in the current Pi
|
|
138
157
|
session namespace. The workers are killed concurrently, so shutdown time does
|
package/index.ts
CHANGED
|
@@ -43,7 +43,7 @@ import type {
|
|
|
43
43
|
ToolDefinition,
|
|
44
44
|
} from "@earendil-works/pi-coding-agent";
|
|
45
45
|
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
46
|
-
import { Box, Markdown, Text } from "@earendil-works/pi-tui";
|
|
46
|
+
import { Box, Markdown, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
47
47
|
import { Type, type TSchema } from "typebox";
|
|
48
48
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
49
49
|
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents";
|
|
@@ -1449,36 +1449,63 @@ export function deliverProcessCompletionMessage(
|
|
|
1449
1449
|
// parked-turn detection (shared rule with self-reap.ts)
|
|
1450
1450
|
// ---------------------------------------------------------------------------
|
|
1451
1451
|
|
|
1452
|
-
// A turn that ends
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1455
|
-
//
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1452
|
+
// A turn that ends after `babysit_run { command }` can be parked awaiting an
|
|
1453
|
+
// automatic process-exit notification. A later terminal babysit_wait/kill for
|
|
1454
|
+
// that same id consumes the start, however, so it must not make a completed
|
|
1455
|
+
// subagent task look parked. (A subagent-profile run does not carry the marker.)
|
|
1456
|
+
// `process` is the legacy pi-processes tool and remains conservative because it
|
|
1457
|
+
// does not expose equivalent structured collection metadata.
|
|
1458
|
+
type ParkedMessage = {
|
|
1459
|
+
role?: string;
|
|
1460
|
+
toolName?: string;
|
|
1461
|
+
content?: unknown;
|
|
1462
|
+
details?: {
|
|
1463
|
+
id?: string;
|
|
1464
|
+
kind?: string;
|
|
1465
|
+
status?: string | { id?: string; state?: string };
|
|
1466
|
+
results?: Array<{ id?: string; kind?: string }>;
|
|
1467
|
+
first?: { id?: string; kind?: string };
|
|
1468
|
+
};
|
|
1469
|
+
};
|
|
1470
|
+
|
|
1471
|
+
function isParkedMessages(messages: ParkedMessage[] | undefined): boolean {
|
|
1469
1472
|
if (!messages) return false;
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
//
|
|
1473
|
+
const terminalCounts = new Map<string, number>();
|
|
1474
|
+
const addTerminal = (id: string) => terminalCounts.set(id, (terminalCounts.get(id) ?? 0) + 1);
|
|
1475
|
+
|
|
1476
|
+
// Walk backwards so only a terminal collection that occurred after a start
|
|
1477
|
+
// can consume it. Counts preserve repeated starts that reuse the same id.
|
|
1474
1478
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1475
1479
|
const message = messages[i];
|
|
1476
1480
|
if (message?.role !== "toolResult") continue;
|
|
1481
|
+
if (message.toolName === "babysit_wait") {
|
|
1482
|
+
const status = message.details?.status;
|
|
1483
|
+
if (
|
|
1484
|
+
status &&
|
|
1485
|
+
typeof status === "object" &&
|
|
1486
|
+
typeof status.id === "string" &&
|
|
1487
|
+
typeof status.state === "string" &&
|
|
1488
|
+
status.state !== "running"
|
|
1489
|
+
) {
|
|
1490
|
+
addTerminal(status.id);
|
|
1491
|
+
}
|
|
1492
|
+
for (const result of message.details?.results ?? []) {
|
|
1493
|
+
if (result.kind === "exited" && typeof result.id === "string") addTerminal(result.id);
|
|
1494
|
+
}
|
|
1495
|
+
const first = message.details?.first;
|
|
1496
|
+
if (first?.kind === "exited" && typeof first.id === "string") addTerminal(first.id);
|
|
1497
|
+
continue;
|
|
1498
|
+
}
|
|
1499
|
+
if (
|
|
1500
|
+
message.toolName === "babysit_kill" &&
|
|
1501
|
+
typeof message.details?.id === "string" &&
|
|
1502
|
+
["exited", "killed", "dead"].includes(String(message.details.status))
|
|
1503
|
+
) {
|
|
1504
|
+
addTerminal(message.details.id);
|
|
1505
|
+
continue;
|
|
1506
|
+
}
|
|
1477
1507
|
if (message.toolName === "process") return true; // legacy pi-processes
|
|
1478
1508
|
if (message.toolName !== "babysit_run") continue;
|
|
1479
|
-
if (message.details?.kind === "process" && message.details.status === "started") {
|
|
1480
|
-
return true;
|
|
1481
|
-
}
|
|
1482
1509
|
const text = Array.isArray(message.content)
|
|
1483
1510
|
? message.content
|
|
1484
1511
|
.filter((part): part is { type: "text"; text: string } =>
|
|
@@ -1489,7 +1516,16 @@ function isParkedMessages(
|
|
|
1489
1516
|
: typeof message.content === "string"
|
|
1490
1517
|
? message.content
|
|
1491
1518
|
: "";
|
|
1492
|
-
|
|
1519
|
+
const textMatch = /^Process started \(id: ([^)]+)\)\. \[notify-on-exit\]\nLog: /.exec(text);
|
|
1520
|
+
const structuredStart =
|
|
1521
|
+
message.details?.kind === "process" && message.details.status === "started";
|
|
1522
|
+
if (!structuredStart && !textMatch) continue;
|
|
1523
|
+
const id = typeof message.details?.id === "string" ? message.details.id : textMatch?.[1];
|
|
1524
|
+
if (!id) return true;
|
|
1525
|
+
const count = terminalCounts.get(id) ?? 0;
|
|
1526
|
+
if (count === 0) return true;
|
|
1527
|
+
if (count === 1) terminalCounts.delete(id);
|
|
1528
|
+
else terminalCounts.set(id, count - 1);
|
|
1493
1529
|
}
|
|
1494
1530
|
return false;
|
|
1495
1531
|
}
|
|
@@ -1764,16 +1800,7 @@ function parseEventLine(progress: Progress, raw: string): void {
|
|
|
1764
1800
|
progress.lastEndWasProcessWait =
|
|
1765
1801
|
typeof event.piBabysitParked === "boolean"
|
|
1766
1802
|
? event.piBabysitParked
|
|
1767
|
-
: isParkedMessages(
|
|
1768
|
-
event.messages as
|
|
1769
|
-
| {
|
|
1770
|
-
role?: string;
|
|
1771
|
-
toolName?: string;
|
|
1772
|
-
content?: unknown;
|
|
1773
|
-
details?: { kind?: string; status?: string };
|
|
1774
|
-
}[]
|
|
1775
|
-
| undefined,
|
|
1776
|
-
);
|
|
1803
|
+
: isParkedMessages(event.messages as ParkedMessage[] | undefined);
|
|
1777
1804
|
break;
|
|
1778
1805
|
}
|
|
1779
1806
|
case "agent_settled":
|
|
@@ -2260,14 +2287,23 @@ async function spawnSubagent(
|
|
|
2260
2287
|
}
|
|
2261
2288
|
|
|
2262
2289
|
// ---------------------------------------------------------------------------
|
|
2263
|
-
// widget (live counts)
|
|
2290
|
+
// widget (live counts and browsable finished sessions)
|
|
2264
2291
|
// ---------------------------------------------------------------------------
|
|
2265
2292
|
|
|
2293
|
+
export function widgetSessionGroups(sessions: BsSession[], startedAt: (id: string) => number) {
|
|
2294
|
+
const active = sessions.filter((session) => session.state === "running");
|
|
2295
|
+
// Default view stays compact; the finished section pages over every retained session.
|
|
2296
|
+
const finished = sessions.filter((session) => session.state !== "running");
|
|
2297
|
+
const started = new Map(finished.map((session) => [session.id, startedAt(session.id)]));
|
|
2298
|
+
finished.sort((a, b) => (started.get(b.id) ?? 0) - (started.get(a.id) ?? 0) || b.id.localeCompare(a.id));
|
|
2299
|
+
return { active, finished };
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2266
2302
|
// A subagent whose task is done stays alive as an idle RPC worker (for
|
|
2267
2303
|
// follow-ups), so "running" in babysit does NOT mean "working" — count
|
|
2268
2304
|
// processes / busy subagents / idle subagents separately.
|
|
2269
2305
|
export type WidgetSessionKind = "process" | "agent";
|
|
2270
|
-
export type WidgetSessionState = "running" | "idle";
|
|
2306
|
+
export type WidgetSessionState = "running" | "idle" | "finished" | "failed";
|
|
2271
2307
|
|
|
2272
2308
|
export function widgetSummaryText(procs: number, busy: number, idle: number): string {
|
|
2273
2309
|
const sections: string[] = [];
|
|
@@ -2285,7 +2321,7 @@ export function widgetSessionHeader(
|
|
|
2285
2321
|
state: WidgetSessionState,
|
|
2286
2322
|
elapsed?: string,
|
|
2287
2323
|
): string {
|
|
2288
|
-
const icon = state === "running" ? "▶" : "○";
|
|
2324
|
+
const icon = state === "running" ? "▶" : state === "failed" ? "✗" : "○";
|
|
2289
2325
|
return ` ${icon} ${id} ${kind.toUpperCase()} ${state.toUpperCase()}${elapsed ? ` age ${elapsed}` : ""}`;
|
|
2290
2326
|
}
|
|
2291
2327
|
|
|
@@ -2311,16 +2347,121 @@ function renderWidgetSessionHeader(
|
|
|
2311
2347
|
elapsed: string,
|
|
2312
2348
|
theme: Theme,
|
|
2313
2349
|
): string {
|
|
2314
|
-
const
|
|
2315
|
-
const icon = theme.fg(running ? "
|
|
2350
|
+
const color = state === "running" ? "success" : state === "failed" ? "error" : "muted";
|
|
2351
|
+
const icon = theme.fg(color, state === "running" ? "▶" : state === "failed" ? "✗" : "○");
|
|
2316
2352
|
const kindLabel = theme.fg(kind === "process" ? "accent" : "warning", theme.bold(kind.toUpperCase()));
|
|
2317
|
-
const stateLabel = theme.fg(
|
|
2353
|
+
const stateLabel = theme.fg(color, theme.bold(state.toUpperCase()));
|
|
2318
2354
|
return ` ${icon} ${id} ${kindLabel} ${stateLabel}${elapsed ? theme.fg("dim", ` age ${elapsed}`) : ""}`;
|
|
2319
2355
|
}
|
|
2320
2356
|
|
|
2321
|
-
// How many trailing output lines to show per
|
|
2322
|
-
const
|
|
2357
|
+
// How many trailing output lines to show per session in the widget.
|
|
2358
|
+
const WIDGET_COLLAPSED_TAIL_LINES = 1;
|
|
2359
|
+
const WIDGET_EXPANDED_TAIL_LINES = 20;
|
|
2323
2360
|
const WIDGET_TAIL_WIDTH = 100;
|
|
2361
|
+
const WIDGET_FINISHED_PAGE_SIZE = 5;
|
|
2362
|
+
|
|
2363
|
+
export interface WidgetFinishedState {
|
|
2364
|
+
open: boolean;
|
|
2365
|
+
page: number;
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
export interface WidgetSessionDisplay {
|
|
2369
|
+
id: string;
|
|
2370
|
+
header: string;
|
|
2371
|
+
tail: string[];
|
|
2372
|
+
expandable: boolean;
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
interface WidgetMouseEvent {
|
|
2376
|
+
type: string;
|
|
2377
|
+
button: string;
|
|
2378
|
+
y: number;
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
/**
|
|
2382
|
+
* Build the live widget as a mouse-aware component without depending on newer
|
|
2383
|
+
* pi-tui mouse classes. Older hosts still render it normally; fullscreen hosts
|
|
2384
|
+
* dispatch component-local click coordinates to handleMouse.
|
|
2385
|
+
*/
|
|
2386
|
+
export function createWidgetComponent(
|
|
2387
|
+
summaryLines: string[],
|
|
2388
|
+
sessions: WidgetSessionDisplay[],
|
|
2389
|
+
expandedSessionIds: Set<string>,
|
|
2390
|
+
finished?: { sessions: WidgetSessionDisplay[]; state: WidgetFinishedState },
|
|
2391
|
+
) {
|
|
2392
|
+
type Target = { type: "session"; id: string } | { type: "finished" | "prev" | "next" };
|
|
2393
|
+
let targetByLine: Array<Target | undefined> = [];
|
|
2394
|
+
|
|
2395
|
+
return {
|
|
2396
|
+
render(width: number): string[] {
|
|
2397
|
+
const lines: string[] = [];
|
|
2398
|
+
targetByLine = [];
|
|
2399
|
+
const push = (text: string, target?: Target) => {
|
|
2400
|
+
lines.push(truncateToWidth(text, Math.max(0, width), ""));
|
|
2401
|
+
targetByLine.push(target);
|
|
2402
|
+
};
|
|
2403
|
+
const pushSession = (session: WidgetSessionDisplay) => {
|
|
2404
|
+
const expanded = session.expandable && expandedSessionIds.has(session.id);
|
|
2405
|
+
const tail = session.tail.slice(
|
|
2406
|
+
expanded ? -WIDGET_EXPANDED_TAIL_LINES : -WIDGET_COLLAPSED_TAIL_LINES,
|
|
2407
|
+
);
|
|
2408
|
+
const target = session.expandable ? { type: "session" as const, id: session.id } : undefined;
|
|
2409
|
+
if (!expanded && tail.length === 1) {
|
|
2410
|
+
push(`${session.header} ${tail[0]}`, target);
|
|
2411
|
+
} else {
|
|
2412
|
+
push(session.header, target);
|
|
2413
|
+
for (const line of tail) push(` ${line}`, target);
|
|
2414
|
+
if (expanded && tail.length === 0) push(" (no output available)", target);
|
|
2415
|
+
}
|
|
2416
|
+
};
|
|
2417
|
+
|
|
2418
|
+
for (const line of summaryLines) push(line);
|
|
2419
|
+
if (finished?.sessions.length) {
|
|
2420
|
+
const pageCount = Math.ceil(finished.sessions.length / WIDGET_FINISHED_PAGE_SIZE);
|
|
2421
|
+
finished.state.page = Math.min(Math.max(0, finished.state.page), pageCount - 1);
|
|
2422
|
+
push(
|
|
2423
|
+
` FINISHED ${finished.sessions.length} — ${finished.state.open ? "hide" : "browse"} (click)`,
|
|
2424
|
+
{ type: "finished" },
|
|
2425
|
+
);
|
|
2426
|
+
if (finished.state.open) {
|
|
2427
|
+
if (pageCount > 1) {
|
|
2428
|
+
if (finished.state.page > 0) push(" ← Previous", { type: "prev" });
|
|
2429
|
+
push(` ${finished.state.page === pageCount - 1 ? "↻ First" : "Next →"} (${finished.state.page + 1}/${pageCount})`, { type: "next" });
|
|
2430
|
+
}
|
|
2431
|
+
const start = finished.state.page * WIDGET_FINISHED_PAGE_SIZE;
|
|
2432
|
+
for (const session of finished.sessions.slice(start, start + WIDGET_FINISHED_PAGE_SIZE)) {
|
|
2433
|
+
pushSession(session);
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
for (const session of sessions) pushSession(session);
|
|
2438
|
+
return lines;
|
|
2439
|
+
},
|
|
2440
|
+
handleMouse(event: WidgetMouseEvent) {
|
|
2441
|
+
if (event.button !== "left") return undefined;
|
|
2442
|
+
const target = targetByLine[event.y];
|
|
2443
|
+
if (!target) return undefined;
|
|
2444
|
+
// Toggle on press so a terminal that omits release still works.
|
|
2445
|
+
if (event.type === "press") {
|
|
2446
|
+
if (target.type === "session") {
|
|
2447
|
+
if (expandedSessionIds.has(target.id)) expandedSessionIds.delete(target.id);
|
|
2448
|
+
else expandedSessionIds.add(target.id);
|
|
2449
|
+
} else if (finished) {
|
|
2450
|
+
if (target.type === "finished") finished.state.open = !finished.state.open;
|
|
2451
|
+
else if (target.type === "prev") finished.state.page--;
|
|
2452
|
+
else finished.state.page = (finished.state.page + 1) % Math.ceil(finished.sessions.length / WIDGET_FINISHED_PAGE_SIZE);
|
|
2453
|
+
}
|
|
2454
|
+
return { handled: true, render: true };
|
|
2455
|
+
}
|
|
2456
|
+
// Consume Pi's synthesized click without toggling a second time.
|
|
2457
|
+
if (event.type === "click") return { handled: true, render: false };
|
|
2458
|
+
return undefined;
|
|
2459
|
+
},
|
|
2460
|
+
invalidate() {
|
|
2461
|
+
targetByLine = [];
|
|
2462
|
+
},
|
|
2463
|
+
};
|
|
2464
|
+
}
|
|
2324
2465
|
|
|
2325
2466
|
// Strip ANSI/control escapes and clamp width so raw PTY output can't wrap or
|
|
2326
2467
|
// corrupt the widget area.
|
|
@@ -2362,20 +2503,23 @@ function readTailLines(file: string, lines: number, maxBytes = 64_000): string[]
|
|
|
2362
2503
|
}
|
|
2363
2504
|
}
|
|
2364
2505
|
|
|
2365
|
-
// Trailing lines to show for a
|
|
2506
|
+
// Trailing lines to show for a session (sanitized, unprefixed).
|
|
2366
2507
|
// Process tails are read directly from the bounded end of output.log, avoiding
|
|
2367
|
-
// one `babysit log` subprocess per
|
|
2368
|
-
|
|
2508
|
+
// one `babysit log` subprocess per visible process.
|
|
2509
|
+
export function widgetTail(
|
|
2369
2510
|
id: string,
|
|
2370
2511
|
isSub: boolean,
|
|
2371
2512
|
subagentProgress?: Progress,
|
|
2372
|
-
|
|
2513
|
+
lines = WIDGET_COLLAPSED_TAIL_LINES,
|
|
2514
|
+
): string[] {
|
|
2373
2515
|
let raw: string[];
|
|
2374
2516
|
if (!isSub) {
|
|
2375
|
-
raw = readTailLines(logPath(id),
|
|
2517
|
+
raw = readTailLines(logPath(id), lines);
|
|
2376
2518
|
} else {
|
|
2377
2519
|
const progress = subagentProgress ?? taskProgressOf(id).progress;
|
|
2378
|
-
if (progress.
|
|
2520
|
+
if (progress.streamingText.trim()) {
|
|
2521
|
+
raw = progress.streamingText.trim().split("\n");
|
|
2522
|
+
} else if (progress.finalText.trim()) {
|
|
2379
2523
|
raw = progress.finalText.trim().split("\n");
|
|
2380
2524
|
} else if (progress.toolCalls.length > 0) {
|
|
2381
2525
|
raw = progress.toolCalls.map((tool) => tool.summary);
|
|
@@ -2386,7 +2530,7 @@ async function widgetTail(
|
|
|
2386
2530
|
return raw
|
|
2387
2531
|
.map(sanitizeTailLine)
|
|
2388
2532
|
.filter((line) => line.trim().length > 0)
|
|
2389
|
-
.slice(-
|
|
2533
|
+
.slice(-lines);
|
|
2390
2534
|
}
|
|
2391
2535
|
|
|
2392
2536
|
// ---------------------------------------------------------------------------
|
|
@@ -2413,6 +2557,63 @@ interface WaitOutcome {
|
|
|
2413
2557
|
progress?: Progress;
|
|
2414
2558
|
}
|
|
2415
2559
|
|
|
2560
|
+
export type ProcessLifecycle = "attached" | "detached";
|
|
2561
|
+
|
|
2562
|
+
export function resolveProcessLifecycle(
|
|
2563
|
+
requested: ProcessLifecycle | undefined,
|
|
2564
|
+
waitsInline: boolean,
|
|
2565
|
+
): ProcessLifecycle {
|
|
2566
|
+
return requested ?? (waitsInline ? "attached" : "detached");
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
async function terminateAttachedProcessAfterInterrupt(id: string): Promise<WaitOutcome> {
|
|
2570
|
+
const killed = await bs(["kill", "-s", id, "--json"]);
|
|
2571
|
+
const backendError =
|
|
2572
|
+
killed.code === 0
|
|
2573
|
+
? validateKillResponse(killed.stdout)
|
|
2574
|
+
: killed.stderr.trim() || killed.stdout.trim() || `babysit kill exited ${killed.code}`;
|
|
2575
|
+
const status = await awaitConfirmedTermination(id);
|
|
2576
|
+
const confirmation = resolveKillConfirmation(id, backendError, status?.state);
|
|
2577
|
+
if (!confirmation.confirmed) {
|
|
2578
|
+
return {
|
|
2579
|
+
id,
|
|
2580
|
+
kind: "interrupted",
|
|
2581
|
+
ok: false,
|
|
2582
|
+
text:
|
|
2583
|
+
`Foreground process ${id} was interrupted, but attached lifecycle termination could not be verified: ${confirmation.error}\n` +
|
|
2584
|
+
`Log: ${logPath(id)}`,
|
|
2585
|
+
status,
|
|
2586
|
+
};
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
// The interrupted owning call reports the termination, so the background
|
|
2590
|
+
// poller must not emit a second completion notification.
|
|
2591
|
+
suppressNotify(id, "kill");
|
|
2592
|
+
return {
|
|
2593
|
+
id,
|
|
2594
|
+
kind: "interrupted",
|
|
2595
|
+
ok: false,
|
|
2596
|
+
text:
|
|
2597
|
+
`Foreground process ${id} was interrupted; attached lifecycle cleanup confirmed terminal state.` +
|
|
2598
|
+
(confirmation.warning ? ` Backend warning: ${confirmation.warning}` : "") +
|
|
2599
|
+
`\nLog: ${logPath(id)}`,
|
|
2600
|
+
status,
|
|
2601
|
+
};
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
async function waitForProcessWithLifecycle(
|
|
2605
|
+
id: string,
|
|
2606
|
+
signal: AbortSignal | undefined,
|
|
2607
|
+
lifecycle: ProcessLifecycle,
|
|
2608
|
+
outputSelection?: ProcessOutputSelection,
|
|
2609
|
+
): Promise<WaitOutcome> {
|
|
2610
|
+
const outcome = await waitForExit(id, null, signal, undefined, outputSelection);
|
|
2611
|
+
if (lifecycle !== "attached" || !signal?.aborted || outcome.kind !== "interrupted") {
|
|
2612
|
+
return outcome;
|
|
2613
|
+
}
|
|
2614
|
+
return terminateAttachedProcessAfterInterrupt(id);
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2416
2617
|
export interface NestedUsage {
|
|
2417
2618
|
input: number;
|
|
2418
2619
|
output: number;
|
|
@@ -3215,11 +3416,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
3215
3416
|
}
|
|
3216
3417
|
}
|
|
3217
3418
|
|
|
3419
|
+
const expandedWidgetSessions = new Set<string>();
|
|
3420
|
+
const finishedWidgetState: WidgetFinishedState = { open: false, page: 0 };
|
|
3421
|
+
|
|
3218
3422
|
const refreshWidget = async (ctx: ExtensionContext, snapshot?: BsSession[]) => {
|
|
3219
3423
|
if (!ctx.hasUI) return;
|
|
3220
|
-
const
|
|
3221
|
-
|
|
3222
|
-
);
|
|
3424
|
+
const all = snapshot ?? (await listSessions()).sessions;
|
|
3425
|
+
const { active, finished } = widgetSessionGroups(all, (id) => readMeta(id)?.startedAt ?? 0);
|
|
3223
3426
|
const subs = active.filter((session) => kindOf(session.id) === "subagent");
|
|
3224
3427
|
const procs = active.length - subs.length;
|
|
3225
3428
|
const progressById = new Map<string, Progress>();
|
|
@@ -3231,8 +3434,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
3231
3434
|
}
|
|
3232
3435
|
}
|
|
3233
3436
|
const idle = subs.filter((session) => progressById.get(session.id)?.done).length;
|
|
3437
|
+
const sessionIds = new Set(all.map((session) => session.id));
|
|
3438
|
+
for (const id of expandedWidgetSessions) {
|
|
3439
|
+
if (!sessionIds.has(id)) expandedWidgetSessions.delete(id);
|
|
3440
|
+
}
|
|
3441
|
+
|
|
3234
3442
|
const theme = ctx.ui.theme;
|
|
3235
|
-
const
|
|
3443
|
+
const summaryLines = renderWidgetLines(procs, subs.length - idle, idle, theme);
|
|
3236
3444
|
const tails = await Promise.all(
|
|
3237
3445
|
active.map((session) => {
|
|
3238
3446
|
const isSubagent = kindOf(session.id) === "subagent";
|
|
@@ -3240,30 +3448,67 @@ export default function (pi: ExtensionAPI) {
|
|
|
3240
3448
|
session.id,
|
|
3241
3449
|
isSubagent,
|
|
3242
3450
|
isSubagent ? progressById.get(session.id) : undefined,
|
|
3451
|
+
WIDGET_EXPANDED_TAIL_LINES,
|
|
3243
3452
|
);
|
|
3244
3453
|
}),
|
|
3245
3454
|
);
|
|
3246
|
-
active.
|
|
3455
|
+
const displays = active.map((session, index): WidgetSessionDisplay => {
|
|
3247
3456
|
const isSubagent = kindOf(session.id) === "subagent";
|
|
3248
3457
|
const state: WidgetSessionState = isSubagent && progressById.get(session.id)?.done
|
|
3249
3458
|
? "idle"
|
|
3250
3459
|
: "running";
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3460
|
+
return {
|
|
3461
|
+
id: session.id,
|
|
3462
|
+
header: renderWidgetSessionHeader(
|
|
3463
|
+
session.id,
|
|
3464
|
+
isSubagent ? "agent" : "process",
|
|
3465
|
+
state,
|
|
3466
|
+
elapsedOf(session.id),
|
|
3467
|
+
theme,
|
|
3468
|
+
),
|
|
3469
|
+
tail: tails[index],
|
|
3470
|
+
expandable: true,
|
|
3471
|
+
};
|
|
3472
|
+
});
|
|
3473
|
+
const finishedDisplays = finished.map((session): WidgetSessionDisplay => {
|
|
3474
|
+
const isSubagent = kindOf(session.id) === "subagent";
|
|
3475
|
+
const id = session.id;
|
|
3476
|
+
return {
|
|
3477
|
+
id,
|
|
3478
|
+
header: renderWidgetSessionHeader(
|
|
3479
|
+
id,
|
|
3480
|
+
isSubagent ? "agent" : "process",
|
|
3481
|
+
session.state === "exited" && session.exit_code === 0 ? "finished" : "failed",
|
|
3482
|
+
"",
|
|
3483
|
+
theme,
|
|
3484
|
+
),
|
|
3485
|
+
// Only read/parse the five visible finished sessions on demand.
|
|
3486
|
+
get tail() {
|
|
3487
|
+
try {
|
|
3488
|
+
return widgetTail(id, isSubagent, undefined, WIDGET_EXPANDED_TAIL_LINES);
|
|
3489
|
+
} catch {
|
|
3490
|
+
return [];
|
|
3491
|
+
}
|
|
3492
|
+
},
|
|
3493
|
+
expandable: true,
|
|
3494
|
+
};
|
|
3265
3495
|
});
|
|
3266
|
-
|
|
3496
|
+
|
|
3497
|
+
if (ctx.mode === "tui") {
|
|
3498
|
+
ctx.ui.setWidget(
|
|
3499
|
+
"pi-babysit",
|
|
3500
|
+
() => createWidgetComponent(summaryLines, displays, expandedWidgetSessions, {
|
|
3501
|
+
sessions: finishedDisplays,
|
|
3502
|
+
state: finishedWidgetState,
|
|
3503
|
+
}),
|
|
3504
|
+
{ placement: "belowEditor" },
|
|
3505
|
+
);
|
|
3506
|
+
} else {
|
|
3507
|
+
const component = createWidgetComponent(summaryLines, displays, new Set());
|
|
3508
|
+
ctx.ui.setWidget("pi-babysit", component.render(10_000), {
|
|
3509
|
+
placement: "belowEditor",
|
|
3510
|
+
});
|
|
3511
|
+
}
|
|
3267
3512
|
};
|
|
3268
3513
|
|
|
3269
3514
|
type DisplayStatus = "started" | "running" | "idle" | "success" | "failed" | "terminated";
|
|
@@ -3603,6 +3848,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
3603
3848
|
description: "Process or subagent: wait for completion and return the result in this tool call.",
|
|
3604
3849
|
}),
|
|
3605
3850
|
),
|
|
3851
|
+
lifecycle: Type.Optional(
|
|
3852
|
+
StringEnum(["attached", "detached"] as const, {
|
|
3853
|
+
description:
|
|
3854
|
+
"Process mode only. attached terminates the process tree if the owning inline babysit_run wait is interrupted; detached leaves it running. Defaults to attached for foreground/non-interactive waits and detached for background starts.",
|
|
3855
|
+
}),
|
|
3856
|
+
),
|
|
3606
3857
|
returnPattern: Type.Optional(
|
|
3607
3858
|
Type.String({ description: "Foreground/quick process: return only latest regex matches." }),
|
|
3608
3859
|
),
|
|
@@ -3675,6 +3926,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
3675
3926
|
details: {},
|
|
3676
3927
|
};
|
|
3677
3928
|
}
|
|
3929
|
+
if (isSubagent && params.lifecycle) {
|
|
3930
|
+
return {
|
|
3931
|
+
content: [{ type: "text", text: "`lifecycle` is available only in process mode." }],
|
|
3932
|
+
isError: true,
|
|
3933
|
+
details: {},
|
|
3934
|
+
};
|
|
3935
|
+
}
|
|
3936
|
+
if (!isSubagent && params.lifecycle === "attached" && ctx.hasUI && !params.foreground) {
|
|
3937
|
+
return {
|
|
3938
|
+
content: [{ type: "text", text: "`lifecycle: attached` requires `foreground: true` in interactive process mode." }],
|
|
3939
|
+
isError: true,
|
|
3940
|
+
details: {},
|
|
3941
|
+
};
|
|
3942
|
+
}
|
|
3678
3943
|
if (isSubagent && (params.returnPattern || params.returnLines != null || params.maxBytes != null)) {
|
|
3679
3944
|
return {
|
|
3680
3945
|
content: [{ type: "text", text: "`returnPattern`, `returnLines`, and `maxBytes` are process-output options." }],
|
|
@@ -3705,6 +3970,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
3705
3970
|
|
|
3706
3971
|
// --- process mode ---
|
|
3707
3972
|
if (!isSubagent) {
|
|
3973
|
+
const waitsInline = !ctx.hasUI || params.foreground === true;
|
|
3974
|
+
const lifecycle = resolveProcessLifecycle(params.lifecycle, waitsInline);
|
|
3708
3975
|
if (params.returnPattern) {
|
|
3709
3976
|
try {
|
|
3710
3977
|
new RegExp(params.returnPattern);
|
|
@@ -3746,19 +4013,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
3746
4013
|
// result in interactive mode, avoiding a separate babysit_wait model turn.
|
|
3747
4014
|
// The command remains supervised, logged, killable, and subject to its
|
|
3748
4015
|
// babysit timeout in either case.
|
|
3749
|
-
if (
|
|
4016
|
+
if (waitsInline) {
|
|
3750
4017
|
// The babysit supervisor owns the absolute command timeout. Waiting with
|
|
3751
4018
|
// the same deadline here races its terminal-state write and can return a
|
|
3752
4019
|
// false "still running" result at the boundary, so wait for the
|
|
3753
4020
|
// supervisor's definitive exit instead.
|
|
3754
|
-
let outcome = await
|
|
4021
|
+
let outcome = await waitForProcessWithLifecycle(res.id, _signal, lifecycle, outputSelection);
|
|
3755
4022
|
let retried = false;
|
|
3756
4023
|
if (params.retryOnWorkerDeath && outcome.status?.state === "dead" && outcome.status.exit_code == null) {
|
|
3757
4024
|
const retry = await spawnProcess(spawnOpts);
|
|
3758
4025
|
if (!("error" in retry)) {
|
|
3759
4026
|
res = retry;
|
|
3760
4027
|
retried = true;
|
|
3761
|
-
outcome = await
|
|
4028
|
+
outcome = await waitForProcessWithLifecycle(res.id, _signal, lifecycle, outputSelection);
|
|
3762
4029
|
}
|
|
3763
4030
|
}
|
|
3764
4031
|
if (ctx.hasUI) await refreshWidget(ctx);
|
package/package.json
CHANGED
package/rpc-stream-proxy.mjs
CHANGED
|
@@ -19,14 +19,40 @@ import { pathToFileURL } from "node:url";
|
|
|
19
19
|
|
|
20
20
|
function isParkedMessages(messages) {
|
|
21
21
|
if (!Array.isArray(messages)) return false;
|
|
22
|
+
const terminalCounts = new Map();
|
|
23
|
+
const addTerminal = (id) => terminalCounts.set(id, (terminalCounts.get(id) ?? 0) + 1);
|
|
24
|
+
|
|
22
25
|
for (let index = messages.length - 1; index >= 0; index--) {
|
|
23
26
|
const message = messages[index];
|
|
24
27
|
if (message?.role !== "toolResult") continue;
|
|
28
|
+
if (message.toolName === "babysit_wait") {
|
|
29
|
+
const status = message.details?.status;
|
|
30
|
+
if (
|
|
31
|
+
status &&
|
|
32
|
+
typeof status === "object" &&
|
|
33
|
+
typeof status.id === "string" &&
|
|
34
|
+
typeof status.state === "string" &&
|
|
35
|
+
status.state !== "running"
|
|
36
|
+
) {
|
|
37
|
+
addTerminal(status.id);
|
|
38
|
+
}
|
|
39
|
+
for (const result of message.details?.results ?? []) {
|
|
40
|
+
if (result.kind === "exited" && typeof result.id === "string") addTerminal(result.id);
|
|
41
|
+
}
|
|
42
|
+
const first = message.details?.first;
|
|
43
|
+
if (first?.kind === "exited" && typeof first.id === "string") addTerminal(first.id);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (
|
|
47
|
+
message.toolName === "babysit_kill" &&
|
|
48
|
+
typeof message.details?.id === "string" &&
|
|
49
|
+
["exited", "killed", "dead"].includes(String(message.details.status))
|
|
50
|
+
) {
|
|
51
|
+
addTerminal(message.details.id);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
25
54
|
if (message.toolName === "process") return true;
|
|
26
55
|
if (message.toolName !== "babysit_run") continue;
|
|
27
|
-
if (message.details?.kind === "process" && message.details.status === "started") {
|
|
28
|
-
return true;
|
|
29
|
-
}
|
|
30
56
|
const text = Array.isArray(message.content)
|
|
31
57
|
? message.content
|
|
32
58
|
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
@@ -35,9 +61,16 @@ function isParkedMessages(messages) {
|
|
|
35
61
|
: typeof message.content === "string"
|
|
36
62
|
? message.content
|
|
37
63
|
: "";
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
64
|
+
const textMatch = /^Process started \(id: ([^)]+)\)\. \[notify-on-exit\]\nLog: /.exec(text);
|
|
65
|
+
const structuredStart =
|
|
66
|
+
message.details?.kind === "process" && message.details.status === "started";
|
|
67
|
+
if (!structuredStart && !textMatch) continue;
|
|
68
|
+
const id = typeof message.details?.id === "string" ? message.details.id : textMatch?.[1];
|
|
69
|
+
if (!id) return true;
|
|
70
|
+
const count = terminalCounts.get(id) ?? 0;
|
|
71
|
+
if (count === 0) return true;
|
|
72
|
+
if (count === 1) terminalCounts.delete(id);
|
|
73
|
+
else terminalCounts.set(id, count - 1);
|
|
41
74
|
}
|
|
42
75
|
return false;
|
|
43
76
|
}
|
package/self-reap.ts
CHANGED
|
@@ -36,25 +36,55 @@ function parseDurMs(s: string | undefined): number | null {
|
|
|
36
36
|
|
|
37
37
|
// Same parked-turn rule as the parent: scan the run because models sometimes
|
|
38
38
|
// add an assistant note after the marker-bearing tool result.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
39
|
+
type ParkedMessage = {
|
|
40
|
+
role?: string;
|
|
41
|
+
toolName?: string;
|
|
42
|
+
content?: unknown;
|
|
43
|
+
details?: {
|
|
44
|
+
id?: string;
|
|
45
|
+
kind?: string;
|
|
46
|
+
status?: string | { id?: string; state?: string };
|
|
47
|
+
results?: Array<{ id?: string; kind?: string }>;
|
|
48
|
+
first?: { id?: string; kind?: string };
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function isParked(messages: ParkedMessage[] | undefined): boolean {
|
|
49
53
|
if (!messages) return false;
|
|
54
|
+
const terminalCounts = new Map<string, number>();
|
|
55
|
+
const addTerminal = (id: string) => terminalCounts.set(id, (terminalCounts.get(id) ?? 0) + 1);
|
|
56
|
+
|
|
50
57
|
for (let index = messages.length - 1; index >= 0; index--) {
|
|
51
58
|
const message = messages[index];
|
|
52
59
|
if (message?.role !== "toolResult") continue;
|
|
60
|
+
if (message.toolName === "babysit_wait") {
|
|
61
|
+
const status = message.details?.status;
|
|
62
|
+
if (
|
|
63
|
+
status &&
|
|
64
|
+
typeof status === "object" &&
|
|
65
|
+
typeof status.id === "string" &&
|
|
66
|
+
typeof status.state === "string" &&
|
|
67
|
+
status.state !== "running"
|
|
68
|
+
) {
|
|
69
|
+
addTerminal(status.id);
|
|
70
|
+
}
|
|
71
|
+
for (const result of message.details?.results ?? []) {
|
|
72
|
+
if (result.kind === "exited" && typeof result.id === "string") addTerminal(result.id);
|
|
73
|
+
}
|
|
74
|
+
const first = message.details?.first;
|
|
75
|
+
if (first?.kind === "exited" && typeof first.id === "string") addTerminal(first.id);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (
|
|
79
|
+
message.toolName === "babysit_kill" &&
|
|
80
|
+
typeof message.details?.id === "string" &&
|
|
81
|
+
["exited", "killed", "dead"].includes(String(message.details.status))
|
|
82
|
+
) {
|
|
83
|
+
addTerminal(message.details.id);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
53
86
|
if (message.toolName === "process") return true; // legacy pi-processes
|
|
54
87
|
if (message.toolName !== "babysit_run") continue;
|
|
55
|
-
if (message.details?.kind === "process" && message.details.status === "started") {
|
|
56
|
-
return true;
|
|
57
|
-
}
|
|
58
88
|
const text = Array.isArray(message.content)
|
|
59
89
|
? message.content
|
|
60
90
|
.filter((part): part is { type: "text"; text: string } =>
|
|
@@ -65,7 +95,16 @@ export function isParked(
|
|
|
65
95
|
: typeof message.content === "string"
|
|
66
96
|
? message.content
|
|
67
97
|
: "";
|
|
68
|
-
|
|
98
|
+
const textMatch = /^Process started \(id: ([^)]+)\)\. \[notify-on-exit\]\nLog: /.exec(text);
|
|
99
|
+
const structuredStart =
|
|
100
|
+
message.details?.kind === "process" && message.details.status === "started";
|
|
101
|
+
if (!structuredStart && !textMatch) continue;
|
|
102
|
+
const id = typeof message.details?.id === "string" ? message.details.id : textMatch?.[1];
|
|
103
|
+
if (!id) return true;
|
|
104
|
+
const count = terminalCounts.get(id) ?? 0;
|
|
105
|
+
if (count === 0) return true;
|
|
106
|
+
if (count === 1) terminalCounts.delete(id);
|
|
107
|
+
else terminalCounts.set(id, count - 1);
|
|
69
108
|
}
|
|
70
109
|
return false;
|
|
71
110
|
}
|
|
@@ -92,9 +131,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
92
131
|
});
|
|
93
132
|
|
|
94
133
|
pi.on("agent_end", (event) => {
|
|
95
|
-
const messages = (
|
|
96
|
-
event as { messages?: { role?: string; toolName?: string; content?: unknown }[] }
|
|
97
|
-
).messages;
|
|
134
|
+
const messages = (event as { messages?: ParkedMessage[] }).messages;
|
|
98
135
|
lastEndWasParked = isParked(messages);
|
|
99
136
|
});
|
|
100
137
|
|