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.
Files changed (66) hide show
  1. package/.githooks/pre-commit +22 -0
  2. package/.githooks/pre-push +56 -0
  3. package/CHANGELOG.md +196 -1
  4. package/dist/index.mjs +533 -515
  5. package/docs/publishing.md +29 -0
  6. package/docs/usage.md +1 -1
  7. package/index.ts +3 -3
  8. package/install.mjs +4 -1
  9. package/package.json +8 -5
  10. package/scripts/install-hooks.mjs +32 -0
  11. package/skills/real-test-pi-crew/SKILL.md +57 -18
  12. package/src/config/config-validation.ts +8 -1
  13. package/src/config/defaults.ts +8 -1
  14. package/src/config/env-vars.ts +16 -1
  15. package/src/config/types.ts +28 -4
  16. package/src/extension/command-completions.ts +44 -22
  17. package/src/extension/registration/foreground-run-controller.ts +19 -10
  18. package/src/extension/registration/lifecycle-handlers.ts +6 -491
  19. package/src/extension/registration/lifecycle.ts +7 -90
  20. package/src/extension/registration/render-loop.ts +532 -0
  21. package/src/extension/team-tool/doctor.ts +6 -0
  22. package/src/extension/team-tool/handle-settings.ts +4 -1
  23. package/src/runtime/child-pi/child-pi-spawn.ts +7 -0
  24. package/src/runtime/child-pi/child-pi.ts +37 -2
  25. package/src/runtime/child-pi/pi-version.ts +187 -0
  26. package/src/runtime/classifier/classifier-service.ts +298 -0
  27. package/src/runtime/dispatch-batch.ts +4 -0
  28. package/src/runtime/live-session/live-session-runtime.ts +31 -0
  29. package/src/runtime/model/pi-args.ts +62 -3
  30. package/src/runtime/process/zombie-scanner.ts +168 -7
  31. package/src/runtime/rpc/README.md +64 -25
  32. package/src/runtime/rpc/rpc-worker.ts +146 -26
  33. package/src/runtime/run-worker.ts +89 -15
  34. package/src/runtime/scheduling/run-coalesced-task-group.ts +3 -0
  35. package/src/runtime/skill-instructions.ts +66 -1
  36. package/src/runtime/surface/resolve-surface.ts +34 -19
  37. package/src/runtime/task-runner/child-executor.ts +132 -0
  38. package/src/runtime/task-runner/pre-execution.ts +8 -0
  39. package/src/runtime/task-runner.ts +6 -0
  40. package/src/schema/config-schema.ts +20 -8
  41. package/src/schema/team-tool-schema.ts +4 -2
  42. package/src/state/contracts.ts +5 -0
  43. package/src/ui/dashboard-panes/agents-pane.ts +3 -2
  44. package/src/ui/dashboard-panes/health-pane.ts +6 -1
  45. package/src/ui/dashboard-panes/mailbox-pane.ts +12 -7
  46. package/src/ui/dashboard-panes/plan-pane.ts +5 -2
  47. package/src/ui/dashboard-panes/progress-pane.ts +8 -2
  48. package/src/ui/format-helpers.ts +11 -0
  49. package/src/ui/live-conversation-overlay.ts +5 -3
  50. package/src/ui/live-duration.ts +5 -4
  51. package/src/ui/live-run-sidebar.ts +90 -20
  52. package/src/ui/overlays/mailbox-compose-overlay.ts +31 -1
  53. package/src/ui/rail.ts +14 -1
  54. package/src/ui/run-action-dispatcher.ts +2 -1
  55. package/src/ui/run-dashboard.ts +62 -22
  56. package/src/ui/run-snapshot-cache.ts +32 -0
  57. package/src/ui/settings-overlay.ts +36 -3
  58. package/src/ui/tool-renderers/brief-mode.ts +2 -2
  59. package/src/ui/transcript-viewer.ts +4 -1
  60. package/src/ui/widget/index.ts +10 -2
  61. package/src/ui/widget/task-list.ts +22 -7
  62. package/src/ui/widget/widget-model.ts +5 -4
  63. package/src/ui/widget/widget-renderer.ts +212 -37
  64. package/src/utils/relative-time.ts +11 -3
  65. package/src/utils/socket-path.ts +48 -8
  66. 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).join(" ").trim() || `pid ${pid}`;
167
+ return raw.split("\0").filter(Boolean);
128
168
  } catch {
129
- return `pid ${pid}`;
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
- if (environ.PI_CREW_KIND !== "subagent") continue;
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 = { readProcEnviron, isPidAlive, computeElapsedSec };
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 PROTOTYPE
1
+ # RPC worker transport — W7 (P2-3) EXPERIMENTAL
2
2
 
3
- Opt-in, **not wired into live dispatch**. The production stdio child-pi path
4
- (`child-pi.ts`) is untouched; `run-worker.ts` returns a structured
5
- **not-implemented** result when the RPC transport is requested (see below).
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. Currently produces the structured not-implemented
68
- result (honest prototype) rather than a live spawn.
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 returns not-implemented instead of calling `runRpcWorker`
73
-
74
- The task's honesty clause allowed exactly this: this lane may not live-spawn
75
- pi (Round 2 already spent the live probes), so a live-wired seam would ship an
76
- end-to-end path that NO test can validate — one env var away from silently
77
- degrading real runs. `runRpcWorker` is complete and fake-stream unit tested
78
- (prompt → `agent_settled` → orderly stop → `ChildPiRunResult` mapping, abort
79
- via RPC `abort` command); flipping the seam is a one-line integration change
80
- after a live-fire probe validates:
81
-
82
- - the exact `--mode rpc` argv/env parity with `prepareSpawnContext`
83
- (print-mode argv assembly is stdio-specific),
84
- - session/event stream shape assumptions (`message_end` assistant text →
85
- `rawFinalText`),
86
- - the Round-2 remaining unknowns (`--approve` probe anomaly, rpc3).
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`) + `runRpcWorker` prototype runner.
129
+ `resolveDialogAnswerPolicy`), the live-fire argv builder
130
+ (`buildRpcWorkerArgv`) + the `runRpcWorker` runner the seam calls.
96
131
 
97
- ## Production-ready? Honestly: NO.
132
+ ## Production-ready? Honestly: NOT for general agent configs.
98
133
 
99
- Prototype-grade. Validated only against fake streams
100
- (`test/unit/runtime/rpc/`). No live spawn has been attempted from this code.
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 (prototype).
2
+ * rpc-worker.ts — W7 (P2-3): EXPERIMENTAL RPC worker transport.
3
3
  *
4
- * STATUS — NOT WIRED INTO LIVE DISPATCH (honest prototype):
5
- * `runWorker` (run-worker.ts) consults {@link resolveWorkerTransport}; when it
6
- * resolves "rpc" the seam currently returns a structured not-implemented
7
- * result instead of calling {@link runRpcWorker}. Reason: this lane is
8
- * forbidden from live-spawning pi (Round-2 evidence already covers the live
9
- * probes; unit tests use fake streams only), so the live path would ship
10
- * completely unvalidated. Flipping the seam to call runRpcWorker is the
11
- * integration-phase follow-up AFTER a live-fire probe (see README.md).
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 itself is complete and fake-stream tested:
14
- * prompt → collect session events until `agent_settled` → orderly stop →
15
- * map to ChildPiRunResult. Abort/steer signals send the RPC `abort` command
16
- * before stopping.
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
- * Resolve the worker transport. The env var is the live prototype gate
43
- * (runWorker has no config access — packet §A); the `runtime.workerTransport`
44
- * config key is declared in config-schema.ts/types.ts and reaches this seam
45
- * in the integration phase. FAILSAFE: anything other than exactly "rpc"
46
- * (after trim) resolves to the default stdio transport.
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(read: (name: string) => string | undefined = getCrewEnv): WorkerTransport {
49
- const raw = read(WORKER_TRANSPORT_ENV);
50
- return raw?.trim() === "rpc" ? "rpc" : "stdio";
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 prototype —
86
- * see module header). Never spawned by production dispatch in this lane.
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
- await settledPromise;
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
  }