pi-crew 0.11.7 → 0.11.9
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/.githooks/pre-commit +22 -0
- package/.githooks/pre-push +56 -0
- package/CHANGELOG.md +196 -1
- package/dist/index.mjs +533 -515
- package/docs/publishing.md +29 -0
- package/docs/usage.md +1 -1
- package/index.ts +3 -3
- package/install.mjs +4 -1
- package/package.json +8 -5
- package/scripts/install-hooks.mjs +32 -0
- package/skills/real-test-pi-crew/SKILL.md +57 -18
- package/src/config/config-validation.ts +8 -1
- package/src/config/defaults.ts +8 -1
- package/src/config/env-vars.ts +16 -1
- package/src/config/types.ts +28 -4
- package/src/extension/command-completions.ts +44 -22
- package/src/extension/registration/foreground-run-controller.ts +19 -10
- package/src/extension/registration/lifecycle-handlers.ts +6 -491
- package/src/extension/registration/lifecycle.ts +7 -90
- package/src/extension/registration/render-loop.ts +532 -0
- package/src/extension/team-tool/doctor.ts +6 -0
- package/src/extension/team-tool/handle-settings.ts +4 -1
- package/src/runtime/child-pi/child-pi-spawn.ts +7 -0
- package/src/runtime/child-pi/child-pi.ts +37 -2
- package/src/runtime/child-pi/pi-version.ts +187 -0
- package/src/runtime/classifier/classifier-service.ts +298 -0
- package/src/runtime/dispatch-batch.ts +4 -0
- package/src/runtime/live-session/live-session-runtime.ts +31 -0
- package/src/runtime/model/pi-args.ts +62 -3
- package/src/runtime/process/zombie-scanner.ts +168 -7
- package/src/runtime/rpc/README.md +64 -25
- package/src/runtime/rpc/rpc-worker.ts +146 -26
- package/src/runtime/run-worker.ts +89 -15
- package/src/runtime/scheduling/run-coalesced-task-group.ts +3 -0
- package/src/runtime/skill-instructions.ts +66 -1
- package/src/runtime/surface/resolve-surface.ts +34 -19
- package/src/runtime/task-runner/child-executor.ts +132 -0
- package/src/runtime/task-runner/pre-execution.ts +8 -0
- package/src/runtime/task-runner.ts +6 -0
- package/src/schema/config-schema.ts +20 -8
- package/src/schema/team-tool-schema.ts +4 -2
- package/src/state/contracts.ts +5 -0
- package/src/ui/dashboard-panes/agents-pane.ts +3 -2
- package/src/ui/dashboard-panes/health-pane.ts +6 -1
- package/src/ui/dashboard-panes/mailbox-pane.ts +12 -7
- package/src/ui/dashboard-panes/plan-pane.ts +5 -2
- package/src/ui/dashboard-panes/progress-pane.ts +8 -2
- package/src/ui/format-helpers.ts +11 -0
- package/src/ui/live-conversation-overlay.ts +5 -3
- package/src/ui/live-duration.ts +5 -4
- package/src/ui/live-run-sidebar.ts +90 -20
- package/src/ui/overlays/mailbox-compose-overlay.ts +31 -1
- package/src/ui/rail.ts +14 -1
- package/src/ui/run-action-dispatcher.ts +2 -1
- package/src/ui/run-dashboard.ts +62 -22
- package/src/ui/run-snapshot-cache.ts +32 -0
- package/src/ui/settings-overlay.ts +36 -3
- package/src/ui/tool-renderers/brief-mode.ts +2 -2
- package/src/ui/transcript-viewer.ts +4 -1
- package/src/ui/widget/index.ts +10 -2
- package/src/ui/widget/task-list.ts +22 -7
- package/src/ui/widget/widget-model.ts +5 -4
- package/src/ui/widget/widget-renderer.ts +212 -37
- package/src/utils/relative-time.ts +11 -3
- package/src/utils/socket-path.ts +48 -8
- package/src/utils/visual.ts +32 -2
|
@@ -18,6 +18,22 @@
|
|
|
18
18
|
* is no longer alive (parent crashed/exited without reaping the child). A sub-agent
|
|
19
19
|
* whose parent is still running is NOT a zombie — it's a legitimate in-flight task.
|
|
20
20
|
*
|
|
21
|
+
* R3-16 (2026-10-04): a SECOND class — "foreign pi processes" — is detected via
|
|
22
|
+
* the CLI-entry markers `AI_AGENT=pi` / `PI_CODING_AGENT=true` (SDK docs
|
|
23
|
+
* environment-variables.md:15-16: both are set at runtime by the CLI/RPC entry
|
|
24
|
+
* points and inherited by children). Because they are set POST-exec, they show
|
|
25
|
+
* up in a process's own frozen /proc/<pid>/environ ONLY when that process was
|
|
26
|
+
* spawned BY another pi process — a top-level interactive main session's
|
|
27
|
+
* at-exec environ never carries them. Discriminator hierarchy:
|
|
28
|
+
* 1. `PI_CREW_KIND=subagent` → crew worker (zombies/live arrays, authoritative);
|
|
29
|
+
* 2. markers + pi-looking argv + not self/not an ancestor → foreign, REPORT ONLY.
|
|
30
|
+
* The argv gate matters: every shell/tool/MCP-server process pi spawns INHERITS
|
|
31
|
+
* the markers (uv/python/bash/xclip …), so marker presence alone over-matches.
|
|
32
|
+
* Residual ambiguity: a pi the USER deliberately starts from inside a pi shell
|
|
33
|
+
* tool (nested pi) inherits the markers too and is indistinguishable from a
|
|
34
|
+
* leak. No 100%-reliable discriminator exists for that residue → foreign
|
|
35
|
+
* entries are a WARN listing with caveat, NEVER offered as kill candidates.
|
|
36
|
+
*
|
|
21
37
|
* This module is READ-ONLY. It never kills anything. The caller (doctor --zombies)
|
|
22
38
|
* prints the list and asks for explicit confirmation before any kill.
|
|
23
39
|
*/
|
|
@@ -51,10 +67,29 @@ export interface ZombieScanResult {
|
|
|
51
67
|
zombies: ZombieSubagent[];
|
|
52
68
|
/** Sub-agents whose parent is still alive — shown for transparency, never killed. */
|
|
53
69
|
live: ZombieSubagent[];
|
|
70
|
+
/** R3-16: non-crew pi processes carrying the CLI markers
|
|
71
|
+
* (AI_AGENT=pi / PI_CODING_AGENT=true) with a pi-looking argv. REPORT ONLY —
|
|
72
|
+
* never a kill candidate (a user-started nested pi is indistinguishable
|
|
73
|
+
* from a leaked one; see module header). */
|
|
74
|
+
foreign: ForeignPiProcess[];
|
|
54
75
|
/** Errors encountered while scanning (per-pid). Never aborts the whole scan. */
|
|
55
76
|
errors: string[];
|
|
56
77
|
}
|
|
57
78
|
|
|
79
|
+
export interface ForeignPiProcess {
|
|
80
|
+
pid: number;
|
|
81
|
+
ppid: number;
|
|
82
|
+
/** Parent dead or re-parented to init (ppid=1) — strongest available orphan signal. */
|
|
83
|
+
orphaned: boolean;
|
|
84
|
+
/** argv carries headless markers (`--mode json` / `-p` / `--print`) — an
|
|
85
|
+
* interactive TUI session never does; combined with orphaned this is the
|
|
86
|
+
* highest-confidence "likely leak" pair, still WARN-only. */
|
|
87
|
+
headless: boolean;
|
|
88
|
+
rssKb: number;
|
|
89
|
+
elapsedSec: number | undefined;
|
|
90
|
+
cmd: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
58
93
|
/** Read /proc/<pid>/environ as a key=value record. Returns {} if unreadable. */
|
|
59
94
|
function readProcEnviron(pid: number): Record<string, string> {
|
|
60
95
|
try {
|
|
@@ -121,12 +156,17 @@ function isPidAlive(pid: number): boolean {
|
|
|
121
156
|
}
|
|
122
157
|
|
|
123
158
|
function readProcCmdline(pid: number): string {
|
|
159
|
+
return readProcCmdlineTokens(pid).join(" ").trim() || `pid ${pid}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** /proc/<pid>/cmdline as argv tokens (NUL-split). Empty array if unreadable. */
|
|
163
|
+
function readProcCmdlineTokens(pid: number): string[] {
|
|
124
164
|
try {
|
|
125
165
|
// /proc/<pid>/cmdline is NUL-separated argv.
|
|
126
166
|
const raw = fs.readFileSync(`/proc/${pid}/cmdline`, "utf-8");
|
|
127
|
-
return raw.split("\0").filter(Boolean)
|
|
167
|
+
return raw.split("\0").filter(Boolean);
|
|
128
168
|
} catch {
|
|
129
|
-
return
|
|
169
|
+
return [];
|
|
130
170
|
}
|
|
131
171
|
}
|
|
132
172
|
|
|
@@ -169,13 +209,16 @@ function listCandidatePids(): number[] {
|
|
|
169
209
|
* has no side effects.
|
|
170
210
|
*/
|
|
171
211
|
export function scanZombieSubagents(): ZombieScanResult {
|
|
172
|
-
const result: ZombieScanResult = { zombies: [], live: [], errors: [] };
|
|
212
|
+
const result: ZombieScanResult = { zombies: [], live: [], foreign: [], errors: [] };
|
|
173
213
|
if (process.platform !== "linux") {
|
|
174
214
|
result.errors.push("zombie scan is Linux-only (/proc required); skipping on " + process.platform);
|
|
175
215
|
return result;
|
|
176
216
|
}
|
|
177
217
|
|
|
178
218
|
const myUid = tryGetUid();
|
|
219
|
+
// R3-16: self + ancestors are live hosting sessions (main session / crew
|
|
220
|
+
// host chain) — never reportable as foreign, even when they carry markers.
|
|
221
|
+
const ancestors = collectAncestorPids();
|
|
179
222
|
for (const pid of listCandidatePids()) {
|
|
180
223
|
try {
|
|
181
224
|
// Cheap rejection first: only inspect processes we own (avoid scanning system procs).
|
|
@@ -185,7 +228,33 @@ export function scanZombieSubagents(): ZombieScanResult {
|
|
|
185
228
|
// AUTHORITATIVE GATE: a process is a pi-crew sub-agent ONLY if it carries
|
|
186
229
|
// PI_CREW_KIND=subagent. The user's main session never sets this, so it can
|
|
187
230
|
// never be matched — this is the fix for accidentally killing main sessions.
|
|
188
|
-
|
|
231
|
+
// R3-16: non-crew processes get ONE extra look — the foreign pi branch
|
|
232
|
+
// below (markers + pi-looking argv, REPORT ONLY); all else is invisible.
|
|
233
|
+
if (environ.PI_CREW_KIND !== "subagent") {
|
|
234
|
+
// Exact value match matters: other agents reuse the AI_AGENT name with
|
|
235
|
+
// their own value (claude-code sets AI_AGENT=claude-code_…_agent), and
|
|
236
|
+
// marker INHERITORS (shells, MCP servers, xclip) are filtered by the
|
|
237
|
+
// argv gate below. Crew workers never reach here — the gate above owns
|
|
238
|
+
// them (pi-crew also scrubs these markers from worker env).
|
|
239
|
+
const hasPiMarker = environ.AI_AGENT === "pi" || environ.PI_CODING_AGENT === "true";
|
|
240
|
+
if (hasPiMarker && pid !== process.pid && !ancestors.has(pid)) {
|
|
241
|
+
const tokens = readProcCmdlineTokens(pid);
|
|
242
|
+
if (looksLikePiBinary(tokens)) {
|
|
243
|
+
const foreignStat = readProcStat(pid);
|
|
244
|
+
const ppid = foreignStat?.ppid ?? 0;
|
|
245
|
+
result.foreign.push({
|
|
246
|
+
pid,
|
|
247
|
+
ppid,
|
|
248
|
+
orphaned: ppid <= 1 || !isPidAlive(ppid),
|
|
249
|
+
headless: isHeadlessArgv(tokens),
|
|
250
|
+
rssKb: readProcRssKb(pid),
|
|
251
|
+
elapsedSec: foreignStat?.elapsedSec,
|
|
252
|
+
cmd: readProcCmdline(pid),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
189
258
|
|
|
190
259
|
const crewParentPid = Number.parseInt(environ.PI_CREW_PARENT_PID ?? "", 10);
|
|
191
260
|
const stat = readProcStat(pid);
|
|
@@ -216,9 +285,11 @@ export function scanZombieSubagents(): ZombieScanResult {
|
|
|
216
285
|
}
|
|
217
286
|
}
|
|
218
287
|
|
|
219
|
-
// Sort: zombies first by descending RSS (biggest leaks first), live by pid
|
|
288
|
+
// Sort: zombies first by descending RSS (biggest leaks first), live by pid;
|
|
289
|
+
// foreign: orphans first (the likely leaks), then by descending RSS.
|
|
220
290
|
result.zombies.sort((a, b) => b.rssKb - a.rssKb);
|
|
221
291
|
result.live.sort((a, b) => a.pid - b.pid);
|
|
292
|
+
result.foreign.sort((a, b) => Number(b.orphaned) - Number(a.orphaned) || b.rssKb - a.rssKb);
|
|
222
293
|
return result;
|
|
223
294
|
}
|
|
224
295
|
|
|
@@ -241,6 +312,63 @@ function getProcUid(pid: number): number | undefined {
|
|
|
241
312
|
}
|
|
242
313
|
}
|
|
243
314
|
|
|
315
|
+
function pathBasename(p: string): string {
|
|
316
|
+
const idx = p.lastIndexOf("/");
|
|
317
|
+
return idx === -1 ? p : p.slice(idx + 1);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* R3-16: does the argv look like the pi CLI binary itself?
|
|
322
|
+
*
|
|
323
|
+
* Every process pi spawns (bash, uv, python MCP servers, xclip, …) INHERITS the
|
|
324
|
+
* AI_AGENT/PI_CODING_AGENT markers, so marker presence alone over-matches the
|
|
325
|
+
* user's own tool processes. Only pi-looking argv[0..1] counts (argv[0] itself,
|
|
326
|
+
* or a node-style script path at argv[1]): basename `pi` (covers
|
|
327
|
+
* /usr/local/bin/pi, .nvm/…/bin/pi, ./pi), `pi-coding-agent`, or a path
|
|
328
|
+
* containing pi-coding-agent (node …/pi-coding-agent/dist/…). Tokens deeper
|
|
329
|
+
* than argv[1] are ARGUMENTS (e.g. `bash -c "pi …"`) — never the binary.
|
|
330
|
+
*/
|
|
331
|
+
function looksLikePiBinary(tokens: string[]): boolean {
|
|
332
|
+
for (const token of tokens.slice(0, 2)) {
|
|
333
|
+
const base = pathBasename(token);
|
|
334
|
+
if (base === "pi" || base === "pi-coding-agent" || base === "pi.js") return true;
|
|
335
|
+
if (token.includes("pi-coding-agent")) return true;
|
|
336
|
+
}
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** R3-16: headless argv detection — an interactive TUI session never carries these. */
|
|
341
|
+
function isHeadlessArgv(tokens: string[]): boolean {
|
|
342
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
343
|
+
const token = tokens[i] ?? "";
|
|
344
|
+
if (token === "-p" || token === "--print" || token === "--mode=json") return true;
|
|
345
|
+
if (token === "--mode" && tokens[i + 1] === "json") return true;
|
|
346
|
+
}
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* R3-16: the scanner's own ancestor chain (self's parent up to init).
|
|
352
|
+
*
|
|
353
|
+
* Any pi process on this chain is a LIVE hosting session (the user's main
|
|
354
|
+
* session / a crew host) and must never be reported — even in the nested case
|
|
355
|
+
* (worker runs doctor → its parent crew host and the user's main session above
|
|
356
|
+
* it sit on this chain and carry markers when pi-in-pi). Bounded at 64 hops to
|
|
357
|
+
* survive a hypothetical pid cycle.
|
|
358
|
+
*/
|
|
359
|
+
function collectAncestorPids(): Set<number> {
|
|
360
|
+
const chain = new Set<number>();
|
|
361
|
+
let current = process.ppid;
|
|
362
|
+
let guard = 0;
|
|
363
|
+
while (current > 1 && guard++ < 64) {
|
|
364
|
+
chain.add(current);
|
|
365
|
+
const stat = readProcStat(current);
|
|
366
|
+
if (!stat || stat.ppid <= 0) break;
|
|
367
|
+
current = stat.ppid;
|
|
368
|
+
}
|
|
369
|
+
return chain;
|
|
370
|
+
}
|
|
371
|
+
|
|
244
372
|
/**
|
|
245
373
|
* Render a ZombieScanResult as human-readable text for the doctor report.
|
|
246
374
|
* Explicitly labels main-session safety and never suggests killing live parents.
|
|
@@ -252,7 +380,7 @@ export function formatZombieReport(scan: ZombieScanResult): string {
|
|
|
252
380
|
lines.push(`Sub-agents identified by PI_CREW_KIND=subagent marker. Main sessions (no marker) are never listed.`);
|
|
253
381
|
lines.push("");
|
|
254
382
|
|
|
255
|
-
if (scan.zombies.length === 0 && scan.live.length === 0) {
|
|
383
|
+
if (scan.zombies.length === 0 && scan.live.length === 0 && scan.foreign.length === 0) {
|
|
256
384
|
lines.push("No pi-crew sub-agent processes found.");
|
|
257
385
|
if (scan.errors.length > 0) {
|
|
258
386
|
lines.push("");
|
|
@@ -284,6 +412,23 @@ export function formatZombieReport(scan: ZombieScanResult): string {
|
|
|
284
412
|
lines.push("");
|
|
285
413
|
}
|
|
286
414
|
|
|
415
|
+
if (scan.foreign.length > 0) {
|
|
416
|
+
lines.push(`### WARN — Foreign pi processes, REPORT ONLY (${scan.foreign.length})`);
|
|
417
|
+
lines.push("CLI-spawned pi processes (AI_AGENT=pi / PI_CODING_AGENT=true markers) that are");
|
|
418
|
+
lines.push("NOT pi-crew children. A top-level interactive session never carries these");
|
|
419
|
+
lines.push("markers in its own environment (the CLI sets them post-exec) — but a pi YOU");
|
|
420
|
+
lines.push("deliberately started from inside a pi shell looks identical to a leak.");
|
|
421
|
+
lines.push("NO reliable discriminator exists for that residue: verify cmd / session-dir");
|
|
422
|
+
lines.push("/ cwd by hand before ever killing one. These are never kill candidates and");
|
|
423
|
+
lines.push("never feed pane cleanup. This tool never kills.");
|
|
424
|
+
lines.push("");
|
|
425
|
+
lines.push(" PID PPID STATE RSS CMD");
|
|
426
|
+
for (const f of scan.foreign) {
|
|
427
|
+
lines.push(formatForeignRow(f));
|
|
428
|
+
}
|
|
429
|
+
lines.push("");
|
|
430
|
+
}
|
|
431
|
+
|
|
287
432
|
if (scan.errors.length > 0) {
|
|
288
433
|
lines.push(`Scan errors (${scan.errors.length}, first 5 shown):`);
|
|
289
434
|
for (const err of scan.errors.slice(0, 5)) lines.push(` - ${err}`);
|
|
@@ -301,6 +446,14 @@ function formatZombieRow(z: ZombieSubagent): string {
|
|
|
301
446
|
return ` ${String(z.pid).padEnd(9)}${String(z.crewParentPid).padEnd(8)}${formatRss(z.rssKb).padEnd(10)}${(z.role ?? "?").padEnd(14)}${surface.padEnd(16)}${z.cmd.slice(0, 44)}`;
|
|
302
447
|
}
|
|
303
448
|
|
|
449
|
+
/** One table row for the R3-16 foreign section — state carries the triage flags. */
|
|
450
|
+
function formatForeignRow(f: ForeignPiProcess): string {
|
|
451
|
+
// "orphaned" = parent dead/re-parented to init (strongest leak signal);
|
|
452
|
+
// "headless" = --mode json / -p argv (never an interactive TUI session).
|
|
453
|
+
const state = `${f.orphaned ? "orphaned" : "live-parent"}${f.headless ? " headless" : ""}`;
|
|
454
|
+
return ` ${String(f.pid).padEnd(9)}${String(f.ppid).padEnd(8)}${state.padEnd(17)}${formatRss(f.rssKb).padEnd(10)}${f.cmd.slice(0, 40)}`;
|
|
455
|
+
}
|
|
456
|
+
|
|
304
457
|
function formatRss(kb: number): string {
|
|
305
458
|
if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(1)}G`;
|
|
306
459
|
if (kb >= 1024) return `${(kb / 1024).toFixed(0)}M`;
|
|
@@ -308,4 +461,12 @@ function formatRss(kb: number): string {
|
|
|
308
461
|
}
|
|
309
462
|
|
|
310
463
|
// Re-export for tests + callers that want to inspect proc helpers in isolation.
|
|
311
|
-
export const __test = {
|
|
464
|
+
export const __test = {
|
|
465
|
+
readProcEnviron,
|
|
466
|
+
isPidAlive,
|
|
467
|
+
computeElapsedSec,
|
|
468
|
+
readProcCmdlineTokens,
|
|
469
|
+
looksLikePiBinary,
|
|
470
|
+
isHeadlessArgv,
|
|
471
|
+
collectAncestorPids,
|
|
472
|
+
};
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
# RPC worker transport — W7 (P2-3) EXPERIMENTAL
|
|
1
|
+
# RPC worker transport — W7 (P2-3) EXPERIMENTAL
|
|
2
2
|
|
|
3
|
-
Opt-in
|
|
4
|
-
(
|
|
5
|
-
|
|
3
|
+
Opt-in behind `PI_CREW_WORKER_TRANSPORT=rpc`. **WIRED at the run-worker seam as
|
|
4
|
+
of 2026-10-04** (integration phase, lane D6) after the live-fire probe below —
|
|
5
|
+
previously the seam returned a structured not-implemented result. The
|
|
6
|
+
production stdio child-pi path (`child-pi.ts`) is untouched for the default
|
|
7
|
+
transport; the rpc path carries a REDUCED input (see Limitations).
|
|
6
8
|
|
|
7
9
|
## What this is
|
|
8
10
|
|
|
@@ -64,26 +66,58 @@ hand-rolled splitter.
|
|
|
64
66
|
the live gate is env `PI_CREW_WORKER_TRANSPORT` (`runWorker` has no config
|
|
65
67
|
access — packet §A).
|
|
66
68
|
- `PI_CREW_WORKER_TRANSPORT=rpc` — selects the transport at the
|
|
67
|
-
`run-worker.ts` seam.
|
|
68
|
-
|
|
69
|
+
`run-worker.ts` seam. WIRED 2026-10-04: calls `runRpcWorker` for real
|
|
70
|
+
(worker-cap slot semantics identical to stdio; early transport failure →
|
|
71
|
+
one structured-warn fallback to the stdio path inside the same slot).
|
|
69
72
|
- `PI_CREW_RPC_DIALOG_ANSWER=cancel|block` — GATE 2 policy (default `cancel`,
|
|
70
73
|
invalid fails safe to `cancel`).
|
|
71
74
|
|
|
72
|
-
## Why the seam
|
|
73
|
-
|
|
74
|
-
The
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
75
|
+
## Why the seam was not wired before 2026-10-04
|
|
76
|
+
|
|
77
|
+
The original lane was not allowed to live-spawn pi, so a live-wired seam would
|
|
78
|
+
have shipped an end-to-end path that NO test could validate — one env var away
|
|
79
|
+
from silently degrading real runs. The integration phase (this lane) ran the
|
|
80
|
+
live-fire probe FIRST and only then flipped the seam.
|
|
81
|
+
|
|
82
|
+
## Live-fire probe 2026-10-04 — GREEN → seam wired
|
|
83
|
+
|
|
84
|
+
Probe setup: real `pi --mode rpc --no-session` spawned by the unmodified
|
|
85
|
+
frame-client default spawner (`getPiSpawnCommand`), scripts importing directly
|
|
86
|
+
from `src/runtime/rpc/` via `node --experimental-strip-types`, PI_CREW_* /
|
|
87
|
+
PI_TEAMS_* env scrubbed, isolated cwd `/tmp/rpc-lf/`, `timeout -k 5 120` per
|
|
88
|
+
run. Raw artifacts (kept on disk): `/tmp/rpc-lf/probe1{,b}.{log,stdout.txt,stderr.txt}`
|
|
89
|
+
(probe1b = default settings model), `probe2.*` (dialog), `probe3.*` (steer).
|
|
90
|
+
|
|
91
|
+
| # | Item | Result |
|
|
92
|
+
|---|------|--------|
|
|
93
|
+
| a | `extension_ui_request` drain (GATE 1 live) | **24 drained** on probe1b (`setStatus`×20, `setWidget`×3, `notify`×1 — pi-crew widget keys visible in raw frames); 0 malformed, 0 unmatched responses |
|
|
94
|
+
| b | prompt round-trip | ack **24 ms**, `agent_settled` **4.2 s**, `rawFinalText="OK"`, child exit **0**, orderly stdin-end shutdown |
|
|
95
|
+
| c | dialog (confirm) | **executed** (not skipped): a `-e` mini extension opened a real `confirm` → policy `cancel` answered `extension_ui_response {cancelled:true}` → `ui.confirm` resolved `false` → the model literally echoed `"false"`; settled 1.7 s, **no deadlock**, `dialogsCancelled=1` |
|
|
96
|
+
| d | steer mid-turn | `steer` at t+1.6 s → response RTT **53 ms**, disposition **`"queued"`**, final assistant text honored (`"STOP"`), settled 6.5 s, exit 0 |
|
|
97
|
+
|
|
98
|
+
First run (probe1) used `--thinking off` against a reasoning-only model
|
|
99
|
+
(`chiase/deepseek-v4.1-flash`, thinkingLevelMap has only `max`) → the assistant
|
|
100
|
+
message came back `content: []` with usage 0. Not a frame-client defect — the
|
|
101
|
+
turn still completed and settled — but a live argv caveat: **do not pass
|
|
102
|
+
`--thinking off` to reasoning-only models**; the wired seam passes the model
|
|
103
|
+
raw and never emits `--thinking`.
|
|
104
|
+
|
|
105
|
+
### Limitations of the wired rpc path (honest list)
|
|
106
|
+
|
|
107
|
+
- Input mapping is the minimal prototype surface: `cwd`/`task`/`model`/`signal`.
|
|
108
|
+
Agent config, system-prompt files, skills, transcript path, steering file,
|
|
109
|
+
lifecycle/stdout callbacks, `maxTurns`/`graceTurns` remain **stdio-only**.
|
|
110
|
+
- The model string is passed RAW (`--model <model>`), no `applyThinkingSuffix`
|
|
111
|
+
composition — thinking level comes from the settings default.
|
|
112
|
+
- Workers are ephemeral (`--no-session`): no deterministic session identity
|
|
113
|
+
(`--session-id`/`--session-dir`, W2 crash recovery) for rpc yet. Attaching a
|
|
114
|
+
worker to the cwd project's default session would pollute the user's session.
|
|
115
|
+
- `runtime.workerTransport` config key is still NOT plumbed to the seam
|
|
116
|
+
(needs a `parseRuntimeConfig` line in `config-validation.ts`, outside the
|
|
117
|
+
lane's ownership); the env var is the only live gate.
|
|
118
|
+
- Early-failure fallback: only a result with `error` set, NO `rawFinalText`,
|
|
119
|
+
and NOT aborted is retried on stdio (double-execution guard,
|
|
120
|
+
`isEarlyRpcTransportFailure` in run-worker.ts).
|
|
87
121
|
|
|
88
122
|
## Files
|
|
89
123
|
|
|
@@ -92,9 +126,14 @@ after a live-fire probe validates:
|
|
|
92
126
|
orderly stop (stdin end → exit → SIGKILL fallback, kill-once guard).
|
|
93
127
|
- `ui-request-policy.ts` — the two design gates + counters.
|
|
94
128
|
- `rpc-worker.ts` — env resolvers (`resolveWorkerTransport`,
|
|
95
|
-
`resolveDialogAnswerPolicy`)
|
|
129
|
+
`resolveDialogAnswerPolicy`), the live-fire argv builder
|
|
130
|
+
(`buildRpcWorkerArgv`) + the `runRpcWorker` runner the seam calls.
|
|
96
131
|
|
|
97
|
-
## Production-ready? Honestly:
|
|
132
|
+
## Production-ready? Honestly: NOT for general agent configs.
|
|
98
133
|
|
|
99
|
-
|
|
100
|
-
|
|
134
|
+
Live-fire validated for the probe scenarios above (small prompt, one confirm
|
|
135
|
+
dialog, one steer, plain model, default extensions). The stdio transport
|
|
136
|
+
remains the default and the only complete path (agent config, system prompts,
|
|
137
|
+
skills, session identity, retries semantics). Do not enable
|
|
138
|
+
`PI_CREW_WORKER_TRANSPORT=rpc` outside experiments until the Limitations
|
|
139
|
+
list above shrinks.
|
|
@@ -1,22 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* rpc-worker.ts — W7 (P2-3): EXPERIMENTAL RPC worker transport
|
|
2
|
+
* rpc-worker.ts — W7 (P2-3): EXPERIMENTAL RPC worker transport.
|
|
3
3
|
*
|
|
4
|
-
* STATUS —
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
4
|
+
* STATUS — WIRED at the run-worker seam behind PI_CREW_WORKER_TRANSPORT=rpc
|
|
5
|
+
* (integration phase, 2026-10-04), after a LIVE-FIRE probe against a real
|
|
6
|
+
* `pi --mode rpc` process came back GREEN on all four probe items:
|
|
7
|
+
* (a) extension_ui_request drain: 24 drained (setStatus×20, setWidget×3,
|
|
8
|
+
* notify×1 — pi-crew extension widgets visible in the raw frames);
|
|
9
|
+
* (b) prompt round-trip: ack 24ms, agent_settled 4.2s, rawFinalText="OK";
|
|
10
|
+
* (c) dialog (confirm) auto-answered by the "cancel" policy
|
|
11
|
+
* (extension_ui_response cancelled:true → ui.confirm resolved false →
|
|
12
|
+
* the model literally echoed "false"), NO deadlock (settled 1.7s);
|
|
13
|
+
* (d) steer mid-turn: disposition "queued", RTT 53ms, final text honored
|
|
14
|
+
* ("STOP"); all runs exited 0 with orderly stdin-end shutdown.
|
|
15
|
+
* Raw probe artifacts: /tmp/rpc-lf/probe1{,b}.{log,stdout.txt,stderr.txt},
|
|
16
|
+
* probe2.*, probe3.* (2026-10-04, lane D6).
|
|
12
17
|
*
|
|
13
|
-
* runRpcWorker
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
18
|
+
* runRpcWorker: prompt → collect session events until `agent_settled` →
|
|
19
|
+
* orderly stop → map to ChildPiRunResult. Abort/steer signals send the RPC
|
|
20
|
+
* `abort` command before stopping. If the transport fails BEFORE any agent
|
|
21
|
+
* output (early spawn/handshake failure), runWorker falls back to the stdio
|
|
22
|
+
* transport (structured warn, no double-execution of a consumed task).
|
|
23
|
+
*
|
|
24
|
+
* LIFECYCLE HARDENING (DR1/DR5, deep-review 2026-10-05 §2):
|
|
25
|
+
* - DR1 settle-hang: the settle wait is RACED against `client.exited()` and
|
|
26
|
+
* a belt-and-suspenders turn timeout — a child that crashes after the
|
|
27
|
+
* prompt was accepted (or stays live-but-silent) can no longer hold the
|
|
28
|
+
* await open forever (and with it the global worker-cap slot). Both
|
|
29
|
+
* losers map to the early-failure result shape so the stdio fallback
|
|
30
|
+
* engages and the slot is released.
|
|
31
|
+
* - DR5 double-execution: `SettleTracking.agentStarted` records that a
|
|
32
|
+
* session event was observed after the prompt was sent; a failure in that
|
|
33
|
+
* state is NEVER retried (surfaced via result.rpcAgentStarted — see
|
|
34
|
+
* isEarlyRpcTransportFailure), closing the started-but-no-text window.
|
|
17
35
|
*/
|
|
18
36
|
|
|
19
|
-
import { getCrewEnv } from "../../config/env-vars.ts";
|
|
37
|
+
import { getCrewEnv, getCrewEnvInt } from "../../config/env-vars.ts";
|
|
20
38
|
import type { ChildPiRunResult } from "../child-pi/child-pi.ts";
|
|
21
39
|
import { createRpcFrameClient, type RpcFrameClientOptions } from "./frame-client.ts";
|
|
22
40
|
import type { DialogAnswerPolicy } from "./ui-request-policy.ts";
|
|
@@ -25,6 +43,10 @@ import type { DialogAnswerPolicy } from "./ui-request-policy.ts";
|
|
|
25
43
|
export const WORKER_TRANSPORT_ENV = "PI_CREW_WORKER_TRANSPORT";
|
|
26
44
|
/** Env knob for the dialog auto-answer policy (GATE 2). */
|
|
27
45
|
export const RPC_DIALOG_ANSWER_ENV = "PI_CREW_RPC_DIALOG_ANSWER";
|
|
46
|
+
/** Env knob for the DR1 turn timeout (ms) — env-only, RPC stays experimental. */
|
|
47
|
+
export const RPC_TURN_TIMEOUT_ENV = "PI_CREW_RPC_TURN_TIMEOUT_MS";
|
|
48
|
+
/** Default DR1 turn bound: 10 minutes (registry default mirrors this literal). */
|
|
49
|
+
export const DEFAULT_RPC_TURN_TIMEOUT_MS = 600_000;
|
|
28
50
|
|
|
29
51
|
export type WorkerTransport = "stdio" | "rpc";
|
|
30
52
|
|
|
@@ -39,15 +61,44 @@ export interface RpcWorkerInput {
|
|
|
39
61
|
}
|
|
40
62
|
|
|
41
63
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
64
|
+
* Live-fire argv for the RPC transport (probe-verified 2026-10-04).
|
|
65
|
+
*
|
|
66
|
+
* `--no-session` is the SAFE prototype default: a worker spawned without
|
|
67
|
+
* deterministic session identity (`--session-id`/`--session-dir` — stdio-only
|
|
68
|
+
* today, see pi-args.ts W2) would otherwise attach to the cwd project's
|
|
69
|
+
* DEFAULT session and pollute the user's live session. Ephemeral workers are
|
|
70
|
+
* correct worker semantics until rpc-mode session identity is designed.
|
|
71
|
+
*
|
|
72
|
+
* Limitation vs the stdio argv (documented, deliberate): the model string is
|
|
73
|
+
* passed RAW — no applyThinkingSuffix composition, no agent/system-prompt
|
|
74
|
+
* files, no hermetic flags. Those remain stdio-only until each is probed.
|
|
75
|
+
*/
|
|
76
|
+
export function buildRpcWorkerArgv(model?: string): string[] {
|
|
77
|
+
const argv = ["--mode", "rpc", "--no-session"];
|
|
78
|
+
if (model) argv.push("--model", model);
|
|
79
|
+
return argv;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the worker transport. Precedence: env > config > default stdio
|
|
84
|
+
* (the repo's established order — the env var stays the live experimental
|
|
85
|
+
* gate and WINS over config whenever it is set, even when its value is
|
|
86
|
+
* invalid: an operator who sets PI_CREW_WORKER_TRANSPORT is driving via env,
|
|
87
|
+
* so the config value is not consulted behind their back). The config value
|
|
88
|
+
* (runtime.workerTransport, threaded to the seam via WorkerSpawnInput by the
|
|
89
|
+
* callers that carry runtimeConfig — D1/DR2) applies only when env is unset.
|
|
90
|
+
* FAILSAFE: anything other than exactly "rpc" (after trim) resolves to the
|
|
91
|
+
* default stdio transport.
|
|
47
92
|
*/
|
|
48
|
-
export function resolveWorkerTransport(
|
|
49
|
-
|
|
50
|
-
|
|
93
|
+
export function resolveWorkerTransport(
|
|
94
|
+
configValue?: WorkerTransport,
|
|
95
|
+
read: (name: string) => string | undefined = getCrewEnv,
|
|
96
|
+
): WorkerTransport {
|
|
97
|
+
const raw = read(WORKER_TRANSPORT_ENV)?.trim();
|
|
98
|
+
if (raw === "rpc") return "rpc";
|
|
99
|
+
if (raw === "stdio") return "stdio";
|
|
100
|
+
if (raw !== undefined) return "stdio"; // env set but invalid → failsafe stdio
|
|
101
|
+
return configValue === "rpc" ? "rpc" : "stdio";
|
|
51
102
|
}
|
|
52
103
|
|
|
53
104
|
/**
|
|
@@ -60,9 +111,36 @@ export function resolveDialogAnswerPolicy(read: (name: string) => string | undef
|
|
|
60
111
|
return read(RPC_DIALOG_ANSWER_ENV)?.trim() === "block" ? "block" : "cancel";
|
|
61
112
|
}
|
|
62
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Resolve the DR1 turn timeout (ms). Belt-and-suspenders bound for a
|
|
116
|
+
* live-but-silent child: the settle wait also races this timeout, so even a
|
|
117
|
+
* child that never exits cannot hold the worker-cap slot forever. Read via
|
|
118
|
+
* the env registry (getCrewEnvInt — the registry default "600000" applies
|
|
119
|
+
* when unset/unparseable); a parsed value ≤ 0 falls back to the default —
|
|
120
|
+
* there is deliberately NO disable value while RPC stays experimental.
|
|
121
|
+
*/
|
|
122
|
+
export function resolveRpcTurnTimeoutMs(read: (name: string) => number | undefined = getCrewEnvInt): number {
|
|
123
|
+
const value = read(RPC_TURN_TIMEOUT_ENV);
|
|
124
|
+
return typeof value === "number" && value > 0 ? value : DEFAULT_RPC_TURN_TIMEOUT_MS;
|
|
125
|
+
}
|
|
126
|
+
|
|
63
127
|
interface SettleTracking {
|
|
64
128
|
lastAssistantText: string | undefined;
|
|
65
129
|
settled: boolean;
|
|
130
|
+
/**
|
|
131
|
+
* DR5 double-execution guard: true once ANY session event other than
|
|
132
|
+
* `agent_settled` (message_start / message_end / extension_error / …)
|
|
133
|
+
* was observed after the prompt command was WRITTEN. "Written" rather
|
|
134
|
+
* than strictly "after the prompt ACK": the frame client processes a
|
|
135
|
+
* stdout chunk's lines synchronously while the sendCommand promise only
|
|
136
|
+
* resolves in a later microtask — an event coalesced into the same chunk
|
|
137
|
+
* as the ACK would slip past a strict ACK gate, reopening the exact
|
|
138
|
+
* window this guard exists to close. `agent_settled` itself does NOT
|
|
139
|
+
* count (it ends the turn; a settled-with-no-other-event turn is an
|
|
140
|
+
* empty turn, not a started one — keeps prompt-preflight rejections
|
|
141
|
+
* retry-safe).
|
|
142
|
+
*/
|
|
143
|
+
agentStarted: boolean;
|
|
66
144
|
}
|
|
67
145
|
|
|
68
146
|
function extractAssistantText(message: unknown): string | undefined {
|
|
@@ -82,20 +160,26 @@ function extractAssistantText(message: unknown): string | undefined {
|
|
|
82
160
|
}
|
|
83
161
|
|
|
84
162
|
/**
|
|
85
|
-
* Run one worker turn over the RPC frame transport (EXPERIMENTAL
|
|
86
|
-
*
|
|
163
|
+
* Run one worker turn over the RPC frame transport (EXPERIMENTAL — see module
|
|
164
|
+
* header for live-fire status). Spawn failure surfaces as a structured result
|
|
165
|
+
* (error field), never a throw from the transport itself.
|
|
87
166
|
*/
|
|
88
167
|
export async function runRpcWorker(input: RpcWorkerInput): Promise<ChildPiRunResult> {
|
|
89
|
-
const tracking: SettleTracking = { lastAssistantText: undefined, settled: false };
|
|
168
|
+
const tracking: SettleTracking = { lastAssistantText: undefined, settled: false, agentStarted: false };
|
|
90
169
|
let settleResolve: (() => void) | undefined;
|
|
91
170
|
const settledPromise = new Promise<void>((resolve) => {
|
|
92
171
|
settleResolve = resolve;
|
|
93
172
|
});
|
|
94
173
|
let abortError: Error | undefined;
|
|
174
|
+
// DR5: set synchronously right before the prompt command is written (see
|
|
175
|
+
// SettleTracking.agentStarted for why "written", not "ACKed").
|
|
176
|
+
let promptSent = false;
|
|
95
177
|
|
|
96
178
|
const overrides = input.rpc ?? {};
|
|
97
179
|
const client = createRpcFrameClient({
|
|
98
180
|
...overrides,
|
|
181
|
+
// Live-fire argv unless the caller (tests) overrides wholesale.
|
|
182
|
+
argv: overrides.argv ?? buildRpcWorkerArgv(input.model),
|
|
99
183
|
dialogPolicy: overrides.dialogPolicy ?? resolveDialogAnswerPolicy(),
|
|
100
184
|
onEvent: (event) => {
|
|
101
185
|
const record = event as { type?: unknown; message?: unknown };
|
|
@@ -106,6 +190,9 @@ export async function runRpcWorker(input: RpcWorkerInput): Promise<ChildPiRunRes
|
|
|
106
190
|
tracking.settled = true;
|
|
107
191
|
settleResolve?.();
|
|
108
192
|
}
|
|
193
|
+
// DR5: any other session event after the prompt was sent marks the
|
|
194
|
+
// agent turn as begun (see SettleTracking.agentStarted docstring).
|
|
195
|
+
if (promptSent && !tracking.settled) tracking.agentStarted = true;
|
|
109
196
|
},
|
|
110
197
|
});
|
|
111
198
|
|
|
@@ -126,12 +213,41 @@ export async function runRpcWorker(input: RpcWorkerInput): Promise<ChildPiRunRes
|
|
|
126
213
|
input.signal?.addEventListener("abort", onAbort, { once: true });
|
|
127
214
|
|
|
128
215
|
let runError: string | undefined;
|
|
216
|
+
// DR1: belt-and-suspenders turn timeout — even a child that never exits
|
|
217
|
+
// must not hold the settle wait (and the worker-cap slot around it) open.
|
|
218
|
+
const turnTimeoutMs = resolveRpcTurnTimeoutMs();
|
|
219
|
+
let turnTimeoutReject: ((error: Error) => void) | undefined;
|
|
220
|
+
const turnTimeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
221
|
+
turnTimeoutReject = reject;
|
|
222
|
+
});
|
|
223
|
+
const turnTimer = setTimeout(() => {
|
|
224
|
+
turnTimeoutReject?.(new Error(`rpc transport: turn timeout after ${turnTimeoutMs}ms without agent_settled`));
|
|
225
|
+
}, turnTimeoutMs);
|
|
226
|
+
turnTimer.unref?.();
|
|
129
227
|
try {
|
|
228
|
+
promptSent = true;
|
|
130
229
|
await client.sendCommand({ type: "prompt", message: input.task });
|
|
131
|
-
|
|
230
|
+
// DR1: race the settle against process exit and the turn timeout. Both
|
|
231
|
+
// losers carry the early-failure shape (error set, no rawFinalText —
|
|
232
|
+
// unless assistant text was already captured, which correctly blocks
|
|
233
|
+
// retry via the existing predicate); the settled-check inside the exit
|
|
234
|
+
// branch keeps a settle frame that lands in the same tick as the exit
|
|
235
|
+
// from being misread as a crash. Promise.race attaches handlers to all
|
|
236
|
+
// participants, so the losers' later rejections are absorbed (no
|
|
237
|
+
// unhandled-rejection), and the abort branch still resolves
|
|
238
|
+
// settledPromise first (abort is never swallowed).
|
|
239
|
+
await Promise.race([
|
|
240
|
+
settledPromise,
|
|
241
|
+
client.exited().then((code) => {
|
|
242
|
+
if (!tracking.settled) throw new Error("rpc transport: exited before agent_settled");
|
|
243
|
+
return code;
|
|
244
|
+
}),
|
|
245
|
+
turnTimeoutPromise,
|
|
246
|
+
]);
|
|
132
247
|
} catch (error) {
|
|
133
248
|
runError = error instanceof Error ? error.message : String(error);
|
|
134
249
|
} finally {
|
|
250
|
+
clearTimeout(turnTimer);
|
|
135
251
|
input.signal?.removeEventListener("abort", onAbort);
|
|
136
252
|
// biome-ignore lint/suspicious/noEmptyBlockStatements: stop() never rejects by design — void-append .catch per lane rules.
|
|
137
253
|
await client.stop().catch(() => {});
|
|
@@ -146,6 +262,10 @@ export async function runRpcWorker(input: RpcWorkerInput): Promise<ChildPiRunRes
|
|
|
146
262
|
if (runError) result.error = runError;
|
|
147
263
|
else if (abortError && !tracking.settled) result.error = `rpc abort failed: ${abortError.message}`;
|
|
148
264
|
if (tracking.lastAssistantText !== undefined) result.rawFinalText = tracking.lastAssistantText;
|
|
265
|
+
// DR5 bridge: explicit result field (NOT an error-string marker) so the
|
|
266
|
+
// non-retryable contract is testable and greppable — isEarlyRpcTransportFailure
|
|
267
|
+
// consults it (run-worker.ts). Set whenever the agent turn had begun.
|
|
268
|
+
if (tracking.agentStarted) result.rpcAgentStarted = true;
|
|
149
269
|
if (input.signal?.aborted) result.aborted = true;
|
|
150
270
|
return result;
|
|
151
271
|
}
|