@yusukeshib/pi-babysit 0.6.3 → 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 +18 -3
- package/index.ts +291 -86
- 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"` |
|
|
@@ -81,7 +81,9 @@ backgrounding (`… &`, `nohup`, `setsid`, `disown`). Set
|
|
|
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
83
|
`PROCESS RUNNING`, `AGENT RUNNING`, or `AGENT IDLE`. The displayed `age`
|
|
84
|
-
is total session age, not time spent in the current state.
|
|
84
|
+
is total session age, not time spent in the current state. Transcript details
|
|
85
|
+
from tool results, snapshots, and completion notifications collapse or expand
|
|
86
|
+
with Pi's tool-output toggle (`Ctrl+O` by default).
|
|
85
87
|
|
|
86
88
|
## Logs without context flooding
|
|
87
89
|
|
|
@@ -130,7 +132,20 @@ suite at the end. Background subagents must likewise be collected before the
|
|
|
130
132
|
parent task finishes so their answer and nested usage are not lost.
|
|
131
133
|
Set `PI_BABYSIT_ALLOW_BASH=1` only as an explicit emergency escape hatch.
|
|
132
134
|
|
|
133
|
-
##
|
|
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`.
|
|
134
149
|
|
|
135
150
|
A real Pi quit terminates every running process and subagent in the current Pi
|
|
136
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";
|
|
@@ -3294,7 +3464,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3294
3464
|
|
|
3295
3465
|
// Render snapshots and subagent answers INLINE in the transcript as formatted
|
|
3296
3466
|
// markdown, with a semantic status label that remains readable on any theme.
|
|
3297
|
-
pi.registerMessageRenderer("pi-babysit-result", (message,
|
|
3467
|
+
pi.registerMessageRenderer("pi-babysit-result", (message, { expanded }, theme) => {
|
|
3298
3468
|
const d = (message.details ?? {}) as {
|
|
3299
3469
|
title?: string;
|
|
3300
3470
|
body?: string;
|
|
@@ -3305,35 +3475,44 @@ export default function (pi: ExtensionAPI) {
|
|
|
3305
3475
|
const box = new Box(1, 0, (t) => theme.bg("toolSuccessBg", t));
|
|
3306
3476
|
if (d.status) box.addChild(new Text(renderStatus(d.status, theme), 0, 0));
|
|
3307
3477
|
if (d.title) box.addChild(new Text(theme.fg("accent", d.title), 0, 0));
|
|
3308
|
-
box.addChild(new Markdown(body, 0, 0, getMarkdownTheme()));
|
|
3478
|
+
if (expanded && body) box.addChild(new Markdown(body, 0, 0, getMarkdownTheme()));
|
|
3309
3479
|
return box;
|
|
3310
3480
|
});
|
|
3311
3481
|
|
|
3312
3482
|
// Process-end notification rendering with a colored lifecycle label. Keep the
|
|
3313
3483
|
// box background subtle: coloring a potentially large log excerpt is noisy.
|
|
3314
|
-
pi.registerMessageRenderer(
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3484
|
+
pi.registerMessageRenderer(
|
|
3485
|
+
"pi-babysit-process-end",
|
|
3486
|
+
(message, { expanded }, theme) => {
|
|
3487
|
+
const content = typeof message.content === "string" ? message.content : "";
|
|
3488
|
+
const d = (message.details ?? {}) as {
|
|
3489
|
+
status?: DisplayStatus;
|
|
3490
|
+
success?: boolean;
|
|
3491
|
+
exitCode?: number | null;
|
|
3492
|
+
command?: string;
|
|
3493
|
+
count?: number;
|
|
3494
|
+
};
|
|
3495
|
+
const status =
|
|
3496
|
+
d.status ??
|
|
3497
|
+
(d.success ? "success" : d.exitCode == null ? "terminated" : "failed");
|
|
3498
|
+
const payload = d.command
|
|
3499
|
+
? ` ${summarizeNotificationCommand(d.command)}`
|
|
3500
|
+
: d.count && d.count > 1
|
|
3501
|
+
? ` ×${d.count}`
|
|
3502
|
+
: "";
|
|
3503
|
+
const header = theme.fg(
|
|
3504
|
+
"warning",
|
|
3505
|
+
theme.bold(`babysit_run COMMAND${payload}`),
|
|
3506
|
+
);
|
|
3507
|
+
const box = new Box(1, 1, (t) => theme.bg("toolSuccessBg", t));
|
|
3508
|
+
box.addChild(new Text(header, 0, 0));
|
|
3509
|
+
box.addChild(new Text(renderStatus(status, theme), 0, 0));
|
|
3510
|
+
if (expanded && content) {
|
|
3511
|
+
box.addChild(new Text(theme.fg("toolOutput", content), 0, 0));
|
|
3512
|
+
}
|
|
3513
|
+
return box;
|
|
3514
|
+
},
|
|
3515
|
+
);
|
|
3337
3516
|
|
|
3338
3517
|
let polling = false;
|
|
3339
3518
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -3594,6 +3773,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
3594
3773
|
description: "Process or subagent: wait for completion and return the result in this tool call.",
|
|
3595
3774
|
}),
|
|
3596
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
|
+
),
|
|
3597
3782
|
returnPattern: Type.Optional(
|
|
3598
3783
|
Type.String({ description: "Foreground/quick process: return only latest regex matches." }),
|
|
3599
3784
|
),
|
|
@@ -3666,6 +3851,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
3666
3851
|
details: {},
|
|
3667
3852
|
};
|
|
3668
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
|
+
}
|
|
3669
3868
|
if (isSubagent && (params.returnPattern || params.returnLines != null || params.maxBytes != null)) {
|
|
3670
3869
|
return {
|
|
3671
3870
|
content: [{ type: "text", text: "`returnPattern`, `returnLines`, and `maxBytes` are process-output options." }],
|
|
@@ -3696,6 +3895,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
3696
3895
|
|
|
3697
3896
|
// --- process mode ---
|
|
3698
3897
|
if (!isSubagent) {
|
|
3898
|
+
const waitsInline = !ctx.hasUI || params.foreground === true;
|
|
3899
|
+
const lifecycle = resolveProcessLifecycle(params.lifecycle, waitsInline);
|
|
3699
3900
|
if (params.returnPattern) {
|
|
3700
3901
|
try {
|
|
3701
3902
|
new RegExp(params.returnPattern);
|
|
@@ -3737,19 +3938,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
3737
3938
|
// result in interactive mode, avoiding a separate babysit_wait model turn.
|
|
3738
3939
|
// The command remains supervised, logged, killable, and subject to its
|
|
3739
3940
|
// babysit timeout in either case.
|
|
3740
|
-
if (
|
|
3941
|
+
if (waitsInline) {
|
|
3741
3942
|
// The babysit supervisor owns the absolute command timeout. Waiting with
|
|
3742
3943
|
// the same deadline here races its terminal-state write and can return a
|
|
3743
3944
|
// false "still running" result at the boundary, so wait for the
|
|
3744
3945
|
// supervisor's definitive exit instead.
|
|
3745
|
-
let outcome = await
|
|
3946
|
+
let outcome = await waitForProcessWithLifecycle(res.id, _signal, lifecycle, outputSelection);
|
|
3746
3947
|
let retried = false;
|
|
3747
3948
|
if (params.retryOnWorkerDeath && outcome.status?.state === "dead" && outcome.status.exit_code == null) {
|
|
3748
3949
|
const retry = await spawnProcess(spawnOpts);
|
|
3749
3950
|
if (!("error" in retry)) {
|
|
3750
3951
|
res = retry;
|
|
3751
3952
|
retried = true;
|
|
3752
|
-
outcome = await
|
|
3953
|
+
outcome = await waitForProcessWithLifecycle(res.id, _signal, lifecycle, outputSelection);
|
|
3753
3954
|
}
|
|
3754
3955
|
}
|
|
3755
3956
|
if (ctx.hasUI) await refreshWidget(ctx);
|
|
@@ -3950,7 +4151,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3950
4151
|
: "";
|
|
3951
4152
|
return new Text(theme.fg("warning", `${title}${agent}${detail}`), 0, 0);
|
|
3952
4153
|
},
|
|
3953
|
-
renderResult(result, { isPartial }, theme, context) {
|
|
4154
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
3954
4155
|
const details = (result.details ?? {}) as {
|
|
3955
4156
|
kind?: "process" | "subagent";
|
|
3956
4157
|
status?: DisplayStatus;
|
|
@@ -3977,7 +4178,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
3977
4178
|
? "started"
|
|
3978
4179
|
: "success");
|
|
3979
4180
|
const label = renderStatus(status, theme);
|
|
3980
|
-
return new Text(
|
|
4181
|
+
return new Text(
|
|
4182
|
+
expanded && content ? `${label}\n${theme.fg("toolOutput", content)}` : label,
|
|
4183
|
+
0,
|
|
4184
|
+
0,
|
|
4185
|
+
);
|
|
3981
4186
|
},
|
|
3982
4187
|
});
|
|
3983
4188
|
|
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
|
|