@yusukeshib/pi-babysit 0.6.4 → 0.6.5
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 +15 -2
- package/index.ts +251 -59
- 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"` |
|
|
@@ -132,7 +132,20 @@ suite at the end. Background subagents must likewise be collected before the
|
|
|
132
132
|
parent task finishes so their answer and nested usage are not lost.
|
|
133
133
|
Set `PI_BABYSIT_ALLOW_BASH=1` only as an explicit emergency escape hatch.
|
|
134
134
|
|
|
135
|
-
##
|
|
135
|
+
## Process ownership, interruption, shutdown, and reload
|
|
136
|
+
|
|
137
|
+
An inline process wait (`foreground: true`, or process execution in a non-UI
|
|
138
|
+
mode) defaults to `lifecycle: "attached"`. If its owning `babysit_run` tool call
|
|
139
|
+
is interrupted through Pi's AbortSignal, pi-babysit terminates the supervised
|
|
140
|
+
process tree through the babysit session API and verifies persisted terminal
|
|
141
|
+
state before returning. Set `lifecycle: "detached"` explicitly only when a
|
|
142
|
+
foreground wait may be interrupted but the process must continue. Interactive
|
|
143
|
+
background starts are detached; `lifecycle: "attached"` therefore requires
|
|
144
|
+
`foreground: true` in interactive mode.
|
|
145
|
+
|
|
146
|
+
An explicit `babysit_wait` is only a waiter, not the process owner. Interrupting
|
|
147
|
+
it never terminates the process; call `babysit_kill` when termination is wanted.
|
|
148
|
+
This cooperative policy does not claim to detect an abrupt host `SIGKILL`.
|
|
136
149
|
|
|
137
150
|
A real Pi quit terminates every running process and subagent in the current Pi
|
|
138
151
|
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":
|
|
@@ -2319,9 +2346,74 @@ function renderWidgetSessionHeader(
|
|
|
2319
2346
|
}
|
|
2320
2347
|
|
|
2321
2348
|
// How many trailing output lines to show per running session in the widget.
|
|
2322
|
-
const
|
|
2349
|
+
const WIDGET_COLLAPSED_TAIL_LINES = 1;
|
|
2350
|
+
const WIDGET_EXPANDED_TAIL_LINES = 20;
|
|
2323
2351
|
const WIDGET_TAIL_WIDTH = 100;
|
|
2324
2352
|
|
|
2353
|
+
export interface WidgetSessionDisplay {
|
|
2354
|
+
id: string;
|
|
2355
|
+
header: string;
|
|
2356
|
+
tail: string[];
|
|
2357
|
+
expandable: boolean;
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
interface WidgetMouseEvent {
|
|
2361
|
+
type: string;
|
|
2362
|
+
button: string;
|
|
2363
|
+
y: number;
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
/**
|
|
2367
|
+
* Build the live widget as a mouse-aware component without depending on newer
|
|
2368
|
+
* pi-tui mouse classes. Older hosts still render it normally; fullscreen hosts
|
|
2369
|
+
* dispatch component-local click coordinates to handleMouse.
|
|
2370
|
+
*/
|
|
2371
|
+
export function createWidgetComponent(
|
|
2372
|
+
summaryLines: string[],
|
|
2373
|
+
sessions: WidgetSessionDisplay[],
|
|
2374
|
+
expandedProcessIds: Set<string>,
|
|
2375
|
+
) {
|
|
2376
|
+
let processIdByLine: Array<string | undefined> = [];
|
|
2377
|
+
|
|
2378
|
+
return {
|
|
2379
|
+
render(width: number): string[] {
|
|
2380
|
+
const lines: string[] = [];
|
|
2381
|
+
processIdByLine = [];
|
|
2382
|
+
const push = (text: string, processId?: string) => {
|
|
2383
|
+
lines.push(truncateToWidth(text, Math.max(0, width), ""));
|
|
2384
|
+
processIdByLine.push(processId);
|
|
2385
|
+
};
|
|
2386
|
+
|
|
2387
|
+
for (const line of summaryLines) push(line);
|
|
2388
|
+
for (const session of sessions) {
|
|
2389
|
+
const expanded = session.expandable && expandedProcessIds.has(session.id);
|
|
2390
|
+
const tail = session.tail.slice(
|
|
2391
|
+
expanded ? -WIDGET_EXPANDED_TAIL_LINES : -WIDGET_COLLAPSED_TAIL_LINES,
|
|
2392
|
+
);
|
|
2393
|
+
const processId = session.expandable ? session.id : undefined;
|
|
2394
|
+
if (!expanded && tail.length === 1) {
|
|
2395
|
+
push(`${session.header} ${tail[0]}`, processId);
|
|
2396
|
+
} else {
|
|
2397
|
+
push(session.header, processId);
|
|
2398
|
+
for (const line of tail) push(` ${line}`, processId);
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
return lines;
|
|
2402
|
+
},
|
|
2403
|
+
handleMouse(event: WidgetMouseEvent) {
|
|
2404
|
+
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
2405
|
+
const id = processIdByLine[event.y];
|
|
2406
|
+
if (!id) return undefined;
|
|
2407
|
+
if (expandedProcessIds.has(id)) expandedProcessIds.delete(id);
|
|
2408
|
+
else expandedProcessIds.add(id);
|
|
2409
|
+
return { handled: true, render: true };
|
|
2410
|
+
},
|
|
2411
|
+
invalidate() {
|
|
2412
|
+
processIdByLine = [];
|
|
2413
|
+
},
|
|
2414
|
+
};
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2325
2417
|
// Strip ANSI/control escapes and clamp width so raw PTY output can't wrap or
|
|
2326
2418
|
// corrupt the widget area.
|
|
2327
2419
|
function sanitizeTailLine(s: string): string {
|
|
@@ -2369,10 +2461,11 @@ async function widgetTail(
|
|
|
2369
2461
|
id: string,
|
|
2370
2462
|
isSub: boolean,
|
|
2371
2463
|
subagentProgress?: Progress,
|
|
2464
|
+
lines = WIDGET_COLLAPSED_TAIL_LINES,
|
|
2372
2465
|
): Promise<string[]> {
|
|
2373
2466
|
let raw: string[];
|
|
2374
2467
|
if (!isSub) {
|
|
2375
|
-
raw = readTailLines(logPath(id),
|
|
2468
|
+
raw = readTailLines(logPath(id), lines);
|
|
2376
2469
|
} else {
|
|
2377
2470
|
const progress = subagentProgress ?? taskProgressOf(id).progress;
|
|
2378
2471
|
if (progress.finalText.trim()) {
|
|
@@ -2386,7 +2479,7 @@ async function widgetTail(
|
|
|
2386
2479
|
return raw
|
|
2387
2480
|
.map(sanitizeTailLine)
|
|
2388
2481
|
.filter((line) => line.trim().length > 0)
|
|
2389
|
-
.slice(-
|
|
2482
|
+
.slice(-lines);
|
|
2390
2483
|
}
|
|
2391
2484
|
|
|
2392
2485
|
// ---------------------------------------------------------------------------
|
|
@@ -2413,6 +2506,63 @@ interface WaitOutcome {
|
|
|
2413
2506
|
progress?: Progress;
|
|
2414
2507
|
}
|
|
2415
2508
|
|
|
2509
|
+
export type ProcessLifecycle = "attached" | "detached";
|
|
2510
|
+
|
|
2511
|
+
export function resolveProcessLifecycle(
|
|
2512
|
+
requested: ProcessLifecycle | undefined,
|
|
2513
|
+
waitsInline: boolean,
|
|
2514
|
+
): ProcessLifecycle {
|
|
2515
|
+
return requested ?? (waitsInline ? "attached" : "detached");
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
async function terminateAttachedProcessAfterInterrupt(id: string): Promise<WaitOutcome> {
|
|
2519
|
+
const killed = await bs(["kill", "-s", id, "--json"]);
|
|
2520
|
+
const backendError =
|
|
2521
|
+
killed.code === 0
|
|
2522
|
+
? validateKillResponse(killed.stdout)
|
|
2523
|
+
: killed.stderr.trim() || killed.stdout.trim() || `babysit kill exited ${killed.code}`;
|
|
2524
|
+
const status = await awaitConfirmedTermination(id);
|
|
2525
|
+
const confirmation = resolveKillConfirmation(id, backendError, status?.state);
|
|
2526
|
+
if (!confirmation.confirmed) {
|
|
2527
|
+
return {
|
|
2528
|
+
id,
|
|
2529
|
+
kind: "interrupted",
|
|
2530
|
+
ok: false,
|
|
2531
|
+
text:
|
|
2532
|
+
`Foreground process ${id} was interrupted, but attached lifecycle termination could not be verified: ${confirmation.error}\n` +
|
|
2533
|
+
`Log: ${logPath(id)}`,
|
|
2534
|
+
status,
|
|
2535
|
+
};
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
// The interrupted owning call reports the termination, so the background
|
|
2539
|
+
// poller must not emit a second completion notification.
|
|
2540
|
+
suppressNotify(id, "kill");
|
|
2541
|
+
return {
|
|
2542
|
+
id,
|
|
2543
|
+
kind: "interrupted",
|
|
2544
|
+
ok: false,
|
|
2545
|
+
text:
|
|
2546
|
+
`Foreground process ${id} was interrupted; attached lifecycle cleanup confirmed terminal state.` +
|
|
2547
|
+
(confirmation.warning ? ` Backend warning: ${confirmation.warning}` : "") +
|
|
2548
|
+
`\nLog: ${logPath(id)}`,
|
|
2549
|
+
status,
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
async function waitForProcessWithLifecycle(
|
|
2554
|
+
id: string,
|
|
2555
|
+
signal: AbortSignal | undefined,
|
|
2556
|
+
lifecycle: ProcessLifecycle,
|
|
2557
|
+
outputSelection?: ProcessOutputSelection,
|
|
2558
|
+
): Promise<WaitOutcome> {
|
|
2559
|
+
const outcome = await waitForExit(id, null, signal, undefined, outputSelection);
|
|
2560
|
+
if (lifecycle !== "attached" || !signal?.aborted || outcome.kind !== "interrupted") {
|
|
2561
|
+
return outcome;
|
|
2562
|
+
}
|
|
2563
|
+
return terminateAttachedProcessAfterInterrupt(id);
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2416
2566
|
export interface NestedUsage {
|
|
2417
2567
|
input: number;
|
|
2418
2568
|
output: number;
|
|
@@ -3215,6 +3365,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
3215
3365
|
}
|
|
3216
3366
|
}
|
|
3217
3367
|
|
|
3368
|
+
const expandedWidgetProcesses = new Set<string>();
|
|
3369
|
+
|
|
3218
3370
|
const refreshWidget = async (ctx: ExtensionContext, snapshot?: BsSession[]) => {
|
|
3219
3371
|
if (!ctx.hasUI) return;
|
|
3220
3372
|
const active = (snapshot ?? (await listSessions()).sessions).filter(
|
|
@@ -3231,8 +3383,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
3231
3383
|
}
|
|
3232
3384
|
}
|
|
3233
3385
|
const idle = subs.filter((session) => progressById.get(session.id)?.done).length;
|
|
3386
|
+
const activeProcessIds = new Set(
|
|
3387
|
+
active.filter((session) => kindOf(session.id) !== "subagent").map((session) => session.id),
|
|
3388
|
+
);
|
|
3389
|
+
for (const id of expandedWidgetProcesses) {
|
|
3390
|
+
if (!activeProcessIds.has(id)) expandedWidgetProcesses.delete(id);
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3234
3393
|
const theme = ctx.ui.theme;
|
|
3235
|
-
const
|
|
3394
|
+
const summaryLines = renderWidgetLines(procs, subs.length - idle, idle, theme);
|
|
3236
3395
|
const tails = await Promise.all(
|
|
3237
3396
|
active.map((session) => {
|
|
3238
3397
|
const isSubagent = kindOf(session.id) === "subagent";
|
|
@@ -3240,30 +3399,41 @@ export default function (pi: ExtensionAPI) {
|
|
|
3240
3399
|
session.id,
|
|
3241
3400
|
isSubagent,
|
|
3242
3401
|
isSubagent ? progressById.get(session.id) : undefined,
|
|
3402
|
+
isSubagent ? WIDGET_COLLAPSED_TAIL_LINES : WIDGET_EXPANDED_TAIL_LINES,
|
|
3243
3403
|
);
|
|
3244
3404
|
}),
|
|
3245
3405
|
);
|
|
3246
|
-
active.
|
|
3406
|
+
const displays = active.map((session, index): WidgetSessionDisplay => {
|
|
3247
3407
|
const isSubagent = kindOf(session.id) === "subagent";
|
|
3248
3408
|
const state: WidgetSessionState = isSubagent && progressById.get(session.id)?.done
|
|
3249
3409
|
? "idle"
|
|
3250
3410
|
: "running";
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
for (const tail of tails[index]) lines.push(` ${tail}`);
|
|
3264
|
-
}
|
|
3411
|
+
return {
|
|
3412
|
+
id: session.id,
|
|
3413
|
+
header: renderWidgetSessionHeader(
|
|
3414
|
+
session.id,
|
|
3415
|
+
isSubagent ? "agent" : "process",
|
|
3416
|
+
state,
|
|
3417
|
+
elapsedOf(session.id),
|
|
3418
|
+
theme,
|
|
3419
|
+
),
|
|
3420
|
+
tail: tails[index],
|
|
3421
|
+
expandable: !isSubagent,
|
|
3422
|
+
};
|
|
3265
3423
|
});
|
|
3266
|
-
|
|
3424
|
+
|
|
3425
|
+
if (ctx.mode === "tui") {
|
|
3426
|
+
ctx.ui.setWidget(
|
|
3427
|
+
"pi-babysit",
|
|
3428
|
+
() => createWidgetComponent(summaryLines, displays, expandedWidgetProcesses),
|
|
3429
|
+
{ placement: "belowEditor" },
|
|
3430
|
+
);
|
|
3431
|
+
} else {
|
|
3432
|
+
const component = createWidgetComponent(summaryLines, displays, new Set());
|
|
3433
|
+
ctx.ui.setWidget("pi-babysit", component.render(10_000), {
|
|
3434
|
+
placement: "belowEditor",
|
|
3435
|
+
});
|
|
3436
|
+
}
|
|
3267
3437
|
};
|
|
3268
3438
|
|
|
3269
3439
|
type DisplayStatus = "started" | "running" | "idle" | "success" | "failed" | "terminated";
|
|
@@ -3603,6 +3773,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
3603
3773
|
description: "Process or subagent: wait for completion and return the result in this tool call.",
|
|
3604
3774
|
}),
|
|
3605
3775
|
),
|
|
3776
|
+
lifecycle: Type.Optional(
|
|
3777
|
+
StringEnum(["attached", "detached"] as const, {
|
|
3778
|
+
description:
|
|
3779
|
+
"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.",
|
|
3780
|
+
}),
|
|
3781
|
+
),
|
|
3606
3782
|
returnPattern: Type.Optional(
|
|
3607
3783
|
Type.String({ description: "Foreground/quick process: return only latest regex matches." }),
|
|
3608
3784
|
),
|
|
@@ -3675,6 +3851,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
3675
3851
|
details: {},
|
|
3676
3852
|
};
|
|
3677
3853
|
}
|
|
3854
|
+
if (isSubagent && params.lifecycle) {
|
|
3855
|
+
return {
|
|
3856
|
+
content: [{ type: "text", text: "`lifecycle` is available only in process mode." }],
|
|
3857
|
+
isError: true,
|
|
3858
|
+
details: {},
|
|
3859
|
+
};
|
|
3860
|
+
}
|
|
3861
|
+
if (!isSubagent && params.lifecycle === "attached" && ctx.hasUI && !params.foreground) {
|
|
3862
|
+
return {
|
|
3863
|
+
content: [{ type: "text", text: "`lifecycle: attached` requires `foreground: true` in interactive process mode." }],
|
|
3864
|
+
isError: true,
|
|
3865
|
+
details: {},
|
|
3866
|
+
};
|
|
3867
|
+
}
|
|
3678
3868
|
if (isSubagent && (params.returnPattern || params.returnLines != null || params.maxBytes != null)) {
|
|
3679
3869
|
return {
|
|
3680
3870
|
content: [{ type: "text", text: "`returnPattern`, `returnLines`, and `maxBytes` are process-output options." }],
|
|
@@ -3705,6 +3895,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
3705
3895
|
|
|
3706
3896
|
// --- process mode ---
|
|
3707
3897
|
if (!isSubagent) {
|
|
3898
|
+
const waitsInline = !ctx.hasUI || params.foreground === true;
|
|
3899
|
+
const lifecycle = resolveProcessLifecycle(params.lifecycle, waitsInline);
|
|
3708
3900
|
if (params.returnPattern) {
|
|
3709
3901
|
try {
|
|
3710
3902
|
new RegExp(params.returnPattern);
|
|
@@ -3746,19 +3938,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
3746
3938
|
// result in interactive mode, avoiding a separate babysit_wait model turn.
|
|
3747
3939
|
// The command remains supervised, logged, killable, and subject to its
|
|
3748
3940
|
// babysit timeout in either case.
|
|
3749
|
-
if (
|
|
3941
|
+
if (waitsInline) {
|
|
3750
3942
|
// The babysit supervisor owns the absolute command timeout. Waiting with
|
|
3751
3943
|
// the same deadline here races its terminal-state write and can return a
|
|
3752
3944
|
// false "still running" result at the boundary, so wait for the
|
|
3753
3945
|
// supervisor's definitive exit instead.
|
|
3754
|
-
let outcome = await
|
|
3946
|
+
let outcome = await waitForProcessWithLifecycle(res.id, _signal, lifecycle, outputSelection);
|
|
3755
3947
|
let retried = false;
|
|
3756
3948
|
if (params.retryOnWorkerDeath && outcome.status?.state === "dead" && outcome.status.exit_code == null) {
|
|
3757
3949
|
const retry = await spawnProcess(spawnOpts);
|
|
3758
3950
|
if (!("error" in retry)) {
|
|
3759
3951
|
res = retry;
|
|
3760
3952
|
retried = true;
|
|
3761
|
-
outcome = await
|
|
3953
|
+
outcome = await waitForProcessWithLifecycle(res.id, _signal, lifecycle, outputSelection);
|
|
3762
3954
|
}
|
|
3763
3955
|
}
|
|
3764
3956
|
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
|
|