pi-crew 0.11.6 → 0.11.8

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 (58) hide show
  1. package/.githooks/pre-commit +22 -0
  2. package/.githooks/pre-push +56 -0
  3. package/CHANGELOG.md +216 -1
  4. package/dist/index.mjs +531 -508
  5. package/docs/publishing.md +29 -0
  6. package/index.ts +3 -3
  7. package/package.json +9 -6
  8. package/scripts/install-hooks.mjs +32 -0
  9. package/skills/real-test-pi-crew/SKILL.md +31 -18
  10. package/src/config/config-validation.ts +35 -0
  11. package/src/config/env-vars.ts +38 -0
  12. package/src/config/types.ts +71 -0
  13. package/src/extension/registration/foreground-run-controller.ts +19 -10
  14. package/src/extension/registration/lifecycle-handlers.ts +6 -491
  15. package/src/extension/registration/lifecycle.ts +7 -90
  16. package/src/extension/registration/render-loop.ts +502 -0
  17. package/src/extension/team-tool/doctor.ts +6 -0
  18. package/src/prompt/prompt-runtime.ts +33 -4
  19. package/src/runtime/child-pi/child-pi-spawn.ts +14 -0
  20. package/src/runtime/child-pi/child-pi.ts +130 -6
  21. package/src/runtime/child-pi/pi-version.ts +187 -0
  22. package/src/runtime/child-pi/session-recovery.ts +334 -0
  23. package/src/runtime/classifier/classifier-service.ts +298 -0
  24. package/src/runtime/dispatch-batch.ts +4 -0
  25. package/src/runtime/live-session/live-session-runtime.ts +31 -0
  26. package/src/runtime/model/model-scope.ts +141 -21
  27. package/src/runtime/model/pi-args.ts +166 -6
  28. package/src/runtime/peer-dep.ts +24 -0
  29. package/src/runtime/process/zombie-scanner.ts +168 -7
  30. package/src/runtime/rpc/README.md +139 -0
  31. package/src/runtime/rpc/frame-client.ts +328 -0
  32. package/src/runtime/rpc/rpc-worker.ts +271 -0
  33. package/src/runtime/rpc/ui-request-policy.ts +112 -0
  34. package/src/runtime/run-worker.ts +91 -3
  35. package/src/runtime/scheduling/run-coalesced-task-group.ts +3 -0
  36. package/src/runtime/skill-instructions.ts +66 -1
  37. package/src/runtime/surface/resolve-surface.ts +34 -19
  38. package/src/runtime/surface/surface-spawn.ts +8 -1
  39. package/src/runtime/task-output-context.ts +191 -32
  40. package/src/runtime/task-packet.ts +3 -0
  41. package/src/runtime/task-runner/child-executor.ts +172 -2
  42. package/src/runtime/task-runner/post-execution.ts +5 -3
  43. package/src/runtime/task-runner/pre-execution.ts +32 -1
  44. package/src/runtime/task-runner/prompt-builder.ts +10 -2
  45. package/src/runtime/task-runner.ts +6 -0
  46. package/src/schema/config-schema.ts +37 -0
  47. package/src/schema/team-tool-schema.ts +14 -2
  48. package/src/state/contracts.ts +5 -0
  49. package/src/ui/format-helpers.ts +11 -0
  50. package/src/ui/overlays/mailbox-compose-overlay.ts +15 -1
  51. package/src/ui/pi-ui-compat.ts +18 -9
  52. package/src/ui/rail.ts +14 -1
  53. package/src/ui/settings-overlay.ts +11 -2
  54. package/src/ui/widget/index.ts +0 -1
  55. package/src/ui/widget/task-list.ts +10 -3
  56. package/src/ui/widget/widget-renderer.ts +53 -4
  57. package/src/utils/relative-time.ts +11 -3
  58. package/src/utils/visual.ts +32 -2
@@ -0,0 +1,328 @@
1
+ /**
2
+ * frame-client.ts — W7 (P2-3): strict-LF JSONL frame client for the pi RPC mode.
3
+ *
4
+ * WHY NOT the SDK's `RpcClient` (deviation recorded in README.md / review §R2.5):
5
+ * 1. It has NO public API to answer `extension_ui_request` records
6
+ * (`send`/`process` are private; handleLine dispatches non-response records
7
+ * to event listeners only) — answering dialogs is design gate 2.
8
+ * 2. Its `start()` spawns `node <cliPath|dist/cli.js>` cwd-relative — NOT the
9
+ * host pi binary the stdio transport resolves via getPiSpawnCommand, so the
10
+ * two transports would run different pi versions.
11
+ * 3. The process is not injectable — the W7 packet REQUIRES fake-stream tests
12
+ * (no real pi spawn), which RpcClient makes impossible.
13
+ * The PROTOCOL SURFACE is still the SDK's: every record type here is imported
14
+ * from `@earendil-works/pi-coding-agent` (RpcCommand/RpcResponse/
15
+ * RpcExtensionUIRequest/RpcExtensionUIResponse), and the framing follows the
16
+ * SDK's own jsonl spec.
17
+ *
18
+ * FRAMING (SDK modes/rpc/jsonl.d.ts): records are LF-only. Payload strings may
19
+ * contain U+2028/U+2029, so splitting MUST be on "\n" ONLY — Node's readline
20
+ * splits on those separators too and is therefore NOT strict JSONL framing.
21
+ * This module hand-rolls the ~15-line splitter for exactly that reason.
22
+ *
23
+ * Robustness contract (prototype, but honest): a malformed line is COUNTED and
24
+ * dropped — never thrown; a matching valid frame afterwards still resolves.
25
+ */
26
+
27
+ import { spawn } from "node:child_process";
28
+ import type { Readable, Writable } from "node:stream";
29
+ import type { RpcCommand, RpcExtensionUIRequest, RpcExtensionUIResponse } from "@earendil-works/pi-coding-agent";
30
+ import { logInternalError } from "../../utils/internal-error.ts";
31
+ import { getPiSpawnCommand } from "../pi-spawn.ts";
32
+ import { createUiRequestPolicy, type DialogAnswerPolicy, type UiRequestCounters } from "./ui-request-policy.ts";
33
+
34
+ /** Cap on retained raw stdout/stderr text (prototype memory guard). */
35
+ const RAW_OUTPUT_CAP_CHARS = 1_000_000;
36
+ /** Default per-command response timeout — mirrors the SDK RpcClient's 30s. */
37
+ const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
38
+ /** Default orderly-shutdown window before SIGKILL fallback. */
39
+ const DEFAULT_STOP_TIMEOUT_MS = 5_000;
40
+
41
+ /** Process handle the frame client drives. Injectable for fake-stream tests. */
42
+ export interface RpcSpawnHandle {
43
+ stdin: Writable;
44
+ stdout: Readable;
45
+ stderr: Readable;
46
+ /** Resolves with the exit code (null when killed by signal). */
47
+ exit: Promise<number | null>;
48
+ /** Send a signal. Must be idempotent-safe (client guards to kill once). */
49
+ kill(signal?: NodeJS.Signals): void;
50
+ }
51
+
52
+ /** Spawn function: receives the pi argv (after `--mode rpc` etc.), returns a handle. */
53
+ export type RpcSpawnFn = (argv: string[]) => RpcSpawnHandle;
54
+
55
+ /** Default spawner: host pi binary (PI_TEAMS_PI_BIN aware) with piped stdio. */
56
+ function defaultSpawnFn(argv: string[]): RpcSpawnHandle {
57
+ const { command, args } = getPiSpawnCommand(argv);
58
+ const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
59
+ return {
60
+ stdin: child.stdin as Writable,
61
+ stdout: child.stdout as Readable,
62
+ stderr: child.stderr as Readable,
63
+ exit: new Promise<number | null>((resolve) => {
64
+ child.once("exit", (code) => resolve(code));
65
+ }),
66
+ kill: (signal?: NodeJS.Signals) => {
67
+ try {
68
+ child.kill(signal ?? "SIGKILL");
69
+ } catch (error) {
70
+ logInternalError("rpc-frame-client.kill", error, "child.kill failed", "warn");
71
+ }
72
+ },
73
+ };
74
+ }
75
+
76
+ /** Combined transport counters (frame client + ui-request policy). */
77
+ export interface RpcTransportCounters extends UiRequestCounters {
78
+ /** Frames that failed JSON.parse (or were empty lines). */
79
+ malformedFrames: number;
80
+ /** Response records matched to a pending command. */
81
+ responsesMatched: number;
82
+ /** Response records with an unknown/duplicate id (dropped). */
83
+ responsesUnmatched: number;
84
+ }
85
+
86
+ export interface RpcFrameClientOptions {
87
+ /** Injectable spawner (tests pass a fake-stream factory). Default: host pi binary. */
88
+ spawnFn?: RpcSpawnFn;
89
+ /** pi argv WITHOUT the binary (client prepends nothing; default ["--mode","rpc"]). */
90
+ argv?: string[];
91
+ /** GATE 2 dialog policy. Default "cancel" (universal safe answer). */
92
+ dialogPolicy?: DialogAnswerPolicy;
93
+ /** Per-command response timeout (ms). Default 30_000. */
94
+ commandTimeoutMs?: number;
95
+ /** Orderly-shutdown timeout before SIGKILL (ms). Default 5_000. */
96
+ stopTimeoutMs?: number;
97
+ /** Non-response, non-ui-request records (agent session events, extension_error). */
98
+ onEvent?: (event: unknown) => void;
99
+ }
100
+
101
+ interface PendingCommand {
102
+ resolve: (response: unknown) => void;
103
+ reject: (error: Error) => void;
104
+ timer: NodeJS.Timeout;
105
+ }
106
+
107
+ export interface RpcFrameClient {
108
+ /** Send a command; resolves with the success response, rejects on error/timeout. */
109
+ sendCommand(command: RpcCommand): Promise<unknown>;
110
+ /** Write an extension_ui_response directly (policy uses this). */
111
+ sendUiResponse(response: RpcExtensionUIResponse): void;
112
+ /** Counters snapshot (fresh object). */
113
+ counters(): RpcTransportCounters;
114
+ /** Retained raw stdout text (capped). */
115
+ stdoutText(): string;
116
+ /** Retained raw stderr text (capped). */
117
+ stderrText(): string;
118
+ /** The exit promise (resolves when the process exits). */
119
+ exited(): Promise<number | null>;
120
+ /** Orderly stop: end stdin → wait exit → SIGKILL fallback. Idempotent. */
121
+ stop(): Promise<void>;
122
+ }
123
+
124
+ function appendCapped(current: string, chunk: string): string {
125
+ const next = current + chunk;
126
+ return next.length > RAW_OUTPUT_CAP_CHARS ? next.slice(0, RAW_OUTPUT_CAP_CHARS) : next;
127
+ }
128
+
129
+ export function createRpcFrameClient(options: RpcFrameClientOptions = {}): RpcFrameClient {
130
+ const spawnFn = options.spawnFn ?? defaultSpawnFn;
131
+ const argv = options.argv ?? ["--mode", "rpc"];
132
+ const commandTimeoutMs = options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
133
+ const stopTimeoutMs = options.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS;
134
+
135
+ const handle = spawnFn(argv);
136
+
137
+ let stdoutText = "";
138
+ let stderrText = "";
139
+ let malformedFrames = 0;
140
+ let responsesMatched = 0;
141
+ let responsesUnmatched = 0;
142
+ let killed = false;
143
+ let stopPromise: Promise<void> | undefined;
144
+
145
+ const pending = new Map<string, PendingCommand>();
146
+ let sequence = 0;
147
+
148
+ const uiPolicy = createUiRequestPolicy({
149
+ dialogPolicy: options.dialogPolicy ?? "cancel",
150
+ writeResponse: (response) => writeUiResponse(response),
151
+ onWarn: (message) => logInternalError("rpc-frame-client.ui-policy", new Error(message), undefined, "warn"),
152
+ });
153
+
154
+ function writeLine(record: unknown): void {
155
+ try {
156
+ // void-append: the write is fire-and-forget; stream errors are
157
+ // handled by the stdin "error" listener below.
158
+ handle.stdin.write(`${JSON.stringify(record)}\n`);
159
+ } catch (error) {
160
+ logInternalError("rpc-frame-client.write", error, "stdin write failed", "warn");
161
+ }
162
+ }
163
+
164
+ function writeUiResponse(response: RpcExtensionUIRequest | RpcExtensionUIResponse): void {
165
+ writeLine(response);
166
+ }
167
+
168
+ function failPending(error: Error): void {
169
+ for (const entry of pending.values()) {
170
+ clearTimeout(entry.timer);
171
+ entry.reject(error);
172
+ }
173
+ pending.clear();
174
+ }
175
+
176
+ function handleLine(line: string): void {
177
+ if (line.length === 0) {
178
+ malformedFrames++;
179
+ return;
180
+ }
181
+ let parsed: unknown;
182
+ try {
183
+ parsed = JSON.parse(line);
184
+ } catch {
185
+ malformedFrames++;
186
+ logInternalError("rpc-frame-client.frame", new Error(`malformed frame dropped (${line.length} chars)`), undefined, "warn");
187
+ return;
188
+ }
189
+ const record = parsed as Record<string, unknown>;
190
+ if (record && record.type === "response") {
191
+ const id = typeof record.id === "string" ? record.id : undefined;
192
+ const entry = id !== undefined ? pending.get(id) : undefined;
193
+ if (id === undefined || entry === undefined) {
194
+ responsesUnmatched++;
195
+ return;
196
+ }
197
+ pending.delete(id);
198
+ clearTimeout(entry.timer);
199
+ responsesMatched++;
200
+ if (record.success === false) {
201
+ entry.reject(new Error(`rpc command failed: ${String(record.error ?? "unknown error")}`));
202
+ } else {
203
+ entry.resolve(record);
204
+ }
205
+ return;
206
+ }
207
+ if (record && record.type === "extension_ui_request") {
208
+ // Gate 1 + Gate 2 live here (count+drop / auto-answer).
209
+ uiPolicy.handle(parsed as RpcExtensionUIRequest);
210
+ return;
211
+ }
212
+ // Agent session events (`message_start`, `agent_settled`, ...) and
213
+ // anything else the server emits (e.g. extension_error).
214
+ options.onEvent?.(parsed);
215
+ }
216
+
217
+ function waitExitWithin(ms: number): Promise<boolean> {
218
+ return Promise.race([handle.exit.then(() => true), new Promise<boolean>((r) => setTimeout(() => r(false), ms))]);
219
+ }
220
+
221
+ function killOnce(): void {
222
+ if (killed) return;
223
+ killed = true;
224
+ handle.kill("SIGKILL");
225
+ }
226
+
227
+ // ── stdout pump: STRICT LF-only framing (see module header). ──────────
228
+ let buffer = "";
229
+ handle.stdout.setEncoding("utf8");
230
+ handle.stdout.on("data", (chunk: string) => {
231
+ stdoutText = appendCapped(stdoutText, chunk);
232
+ buffer += chunk;
233
+ let newlineAt = buffer.indexOf("\n");
234
+ while (newlineAt >= 0) {
235
+ const line = buffer.slice(0, newlineAt);
236
+ buffer = buffer.slice(newlineAt + 1);
237
+ handleLine(line);
238
+ newlineAt = buffer.indexOf("\n");
239
+ }
240
+ });
241
+ // Final flush + pending-failure. "end" covers orderly EOF; "close" also
242
+ // fires on destroy() (killed stream) where "end" never does — both paths
243
+ // must drain the leftover buffer and fail any still-pending commands.
244
+ let streamFinalized = false;
245
+ function finalizeStream(): void {
246
+ if (streamFinalized) return;
247
+ streamFinalized = true;
248
+ if (buffer.length > 0) {
249
+ // Final line without trailing LF: the server always writes LF, so a
250
+ // non-empty remainder means an abrupt cut — try to parse it anyway
251
+ // (a complete JSON object missing only the terminator is usable),
252
+ // otherwise it lands in malformedFrames.
253
+ const line = buffer;
254
+ buffer = "";
255
+ handleLine(line);
256
+ }
257
+ failPending(new Error("rpc transport: stdout closed before response"));
258
+ }
259
+ handle.stdout.on("end", () => finalizeStream());
260
+ handle.stdout.on("close", () => finalizeStream());
261
+ handle.stdout.on("error", (error) => {
262
+ logInternalError("rpc-frame-client.stdout", error, undefined, "warn");
263
+ failPending(new Error(`rpc transport: stdout error: ${error.message}`));
264
+ });
265
+
266
+ handle.stderr.setEncoding("utf8");
267
+ handle.stderr.on("data", (chunk: string) => {
268
+ stderrText = appendCapped(stderrText, chunk);
269
+ });
270
+ handle.stderr.on("error", (error) => {
271
+ logInternalError("rpc-frame-client.stderr", error, undefined, "warn");
272
+ });
273
+
274
+ // A dead stdin (EPIPE when the child exits first) must not crash the host.
275
+ handle.stdin.on("error", (error) => {
276
+ logInternalError("rpc-frame-client.stdin", error, undefined, "warn");
277
+ });
278
+
279
+ const client: RpcFrameClient = {
280
+ sendCommand(command: RpcCommand): Promise<unknown> {
281
+ sequence++;
282
+ const id = `pi-crew-rpc-${sequence}`;
283
+ const record = { id, ...command } as RpcCommand;
284
+ return new Promise((resolve, reject) => {
285
+ const timer = setTimeout(() => {
286
+ pending.delete(id);
287
+ reject(new Error(`rpc command timeout after ${commandTimeoutMs}ms: ${command.type}`));
288
+ }, commandTimeoutMs);
289
+ pending.set(id, { resolve, reject, timer });
290
+ writeLine(record);
291
+ });
292
+ },
293
+ sendUiResponse(response: RpcExtensionUIResponse): void {
294
+ writeUiResponse(response);
295
+ },
296
+ counters(): RpcTransportCounters {
297
+ return {
298
+ ...uiPolicy.counters(),
299
+ malformedFrames,
300
+ responsesMatched,
301
+ responsesUnmatched,
302
+ };
303
+ },
304
+ stdoutText: () => stdoutText,
305
+ stderrText: () => stderrText,
306
+ exited: () => handle.exit,
307
+ stop(): Promise<void> {
308
+ if (stopPromise) return stopPromise;
309
+ stopPromise = (async () => {
310
+ // Orderly shutdown (rpc-mode.js onInputEnd → server shutdown,
311
+ // exit 0 per R2 live evidence): close stdin, wait, then kill.
312
+ try {
313
+ handle.stdin.end();
314
+ } catch (error) {
315
+ logInternalError("rpc-frame-client.stop", error, "stdin.end failed", "warn");
316
+ }
317
+ const exitedCleanly = await waitExitWithin(stopTimeoutMs);
318
+ if (!exitedCleanly) {
319
+ killOnce();
320
+ await waitExitWithin(2_000);
321
+ }
322
+ failPending(new Error("rpc transport stopped"));
323
+ })();
324
+ return stopPromise;
325
+ },
326
+ };
327
+ return client;
328
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * rpc-worker.ts — W7 (P2-3): EXPERIMENTAL RPC worker transport.
3
+ *
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).
17
+ *
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.
35
+ */
36
+
37
+ import { getCrewEnv, getCrewEnvInt } from "../../config/env-vars.ts";
38
+ import type { ChildPiRunResult } from "../child-pi/child-pi.ts";
39
+ import { createRpcFrameClient, type RpcFrameClientOptions } from "./frame-client.ts";
40
+ import type { DialogAnswerPolicy } from "./ui-request-policy.ts";
41
+
42
+ /** Env knob selecting the transport at the run-worker seam. */
43
+ export const WORKER_TRANSPORT_ENV = "PI_CREW_WORKER_TRANSPORT";
44
+ /** Env knob for the dialog auto-answer policy (GATE 2). */
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;
50
+
51
+ export type WorkerTransport = "stdio" | "rpc";
52
+
53
+ /** Minimal surface of runWorker's input the RPC path consumes. */
54
+ export interface RpcWorkerInput {
55
+ cwd?: string;
56
+ task: string;
57
+ model?: string;
58
+ signal?: AbortSignal;
59
+ /** Optional client overrides (tests inject a fake spawner + tiny timeouts). */
60
+ rpc?: Pick<RpcFrameClientOptions, "spawnFn" | "argv" | "commandTimeoutMs" | "stopTimeoutMs" | "dialogPolicy">;
61
+ }
62
+
63
+ /**
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.
92
+ */
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";
102
+ }
103
+
104
+ /**
105
+ * Resolve the dialog auto-answer policy (GATE 2). "cancel" (default) is the
106
+ * universal safe answer; "block" leaves the server promise pending on purpose
107
+ * (debugging). Auto-confirm is deliberately NOT a policy (security gate — see
108
+ * ui-request-policy.ts). Invalid values fail safe to "cancel".
109
+ */
110
+ export function resolveDialogAnswerPolicy(read: (name: string) => string | undefined = getCrewEnv): DialogAnswerPolicy {
111
+ return read(RPC_DIALOG_ANSWER_ENV)?.trim() === "block" ? "block" : "cancel";
112
+ }
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
+
127
+ interface SettleTracking {
128
+ lastAssistantText: string | undefined;
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;
144
+ }
145
+
146
+ function extractAssistantText(message: unknown): string | undefined {
147
+ if (typeof message !== "object" || message === null) return undefined;
148
+ const role = (message as { role?: unknown }).role;
149
+ if (role !== "assistant") return undefined;
150
+ const content = (message as { content?: unknown }).content;
151
+ if (!Array.isArray(content)) return undefined;
152
+ const parts: string[] = [];
153
+ for (const part of content) {
154
+ if (typeof part === "object" && part !== null && (part as { type?: unknown }).type === "text") {
155
+ const text = (part as { text?: unknown }).text;
156
+ if (typeof text === "string") parts.push(text);
157
+ }
158
+ }
159
+ return parts.length > 0 ? parts.join("\n") : undefined;
160
+ }
161
+
162
+ /**
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.
166
+ */
167
+ export async function runRpcWorker(input: RpcWorkerInput): Promise<ChildPiRunResult> {
168
+ const tracking: SettleTracking = { lastAssistantText: undefined, settled: false, agentStarted: false };
169
+ let settleResolve: (() => void) | undefined;
170
+ const settledPromise = new Promise<void>((resolve) => {
171
+ settleResolve = resolve;
172
+ });
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;
177
+
178
+ const overrides = input.rpc ?? {};
179
+ const client = createRpcFrameClient({
180
+ ...overrides,
181
+ // Live-fire argv unless the caller (tests) overrides wholesale.
182
+ argv: overrides.argv ?? buildRpcWorkerArgv(input.model),
183
+ dialogPolicy: overrides.dialogPolicy ?? resolveDialogAnswerPolicy(),
184
+ onEvent: (event) => {
185
+ const record = event as { type?: unknown; message?: unknown };
186
+ if (record?.type === "message_end") {
187
+ const text = extractAssistantText(record.message);
188
+ if (text !== undefined) tracking.lastAssistantText = text;
189
+ } else if (record?.type === "agent_settled") {
190
+ tracking.settled = true;
191
+ settleResolve?.();
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;
196
+ },
197
+ });
198
+
199
+ const onAbort = () => {
200
+ // Ask the server to abort the current run, then stop orderly. The
201
+ // command may race the shutdown — a rejection is expected and logged.
202
+ client
203
+ .sendCommand({ type: "abort" })
204
+ .catch((error: Error) => {
205
+ abortError = error;
206
+ })
207
+ .finally(() => {
208
+ // biome-ignore lint/suspicious/noEmptyBlockStatements: stop() never rejects by design — void-append .catch per lane rules.
209
+ void client.stop().catch(() => {});
210
+ settleResolve?.();
211
+ });
212
+ };
213
+ input.signal?.addEventListener("abort", onAbort, { once: true });
214
+
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?.();
227
+ try {
228
+ promptSent = true;
229
+ await client.sendCommand({ type: "prompt", message: input.task });
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
+ ]);
247
+ } catch (error) {
248
+ runError = error instanceof Error ? error.message : String(error);
249
+ } finally {
250
+ clearTimeout(turnTimer);
251
+ input.signal?.removeEventListener("abort", onAbort);
252
+ // biome-ignore lint/suspicious/noEmptyBlockStatements: stop() never rejects by design — void-append .catch per lane rules.
253
+ await client.stop().catch(() => {});
254
+ }
255
+
256
+ const exitCode = await client.exited();
257
+ const result: ChildPiRunResult = {
258
+ exitCode,
259
+ stdout: client.stdoutText(),
260
+ stderr: client.stderrText(),
261
+ };
262
+ if (runError) result.error = runError;
263
+ else if (abortError && !tracking.settled) result.error = `rpc abort failed: ${abortError.message}`;
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;
269
+ if (input.signal?.aborted) result.aborted = true;
270
+ return result;
271
+ }