pi-crew 0.11.6 → 0.11.7
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/CHANGELOG.md +95 -0
- package/dist/index.mjs +486 -481
- package/package.json +2 -2
- package/src/config/config-validation.ts +28 -0
- package/src/config/env-vars.ts +23 -0
- package/src/config/types.ts +47 -0
- package/src/prompt/prompt-runtime.ts +33 -4
- package/src/runtime/child-pi/child-pi-spawn.ts +7 -0
- package/src/runtime/child-pi/child-pi.ts +93 -4
- package/src/runtime/child-pi/session-recovery.ts +334 -0
- package/src/runtime/model/model-scope.ts +141 -21
- package/src/runtime/model/pi-args.ts +104 -3
- package/src/runtime/peer-dep.ts +24 -0
- package/src/runtime/rpc/README.md +100 -0
- package/src/runtime/rpc/frame-client.ts +328 -0
- package/src/runtime/rpc/rpc-worker.ts +151 -0
- package/src/runtime/rpc/ui-request-policy.ts +112 -0
- package/src/runtime/run-worker.ts +14 -0
- package/src/runtime/surface/surface-spawn.ts +8 -1
- package/src/runtime/task-output-context.ts +191 -32
- package/src/runtime/task-packet.ts +3 -0
- package/src/runtime/task-runner/child-executor.ts +40 -2
- package/src/runtime/task-runner/post-execution.ts +5 -3
- package/src/runtime/task-runner/pre-execution.ts +24 -1
- package/src/runtime/task-runner/prompt-builder.ts +10 -2
- package/src/schema/config-schema.ts +25 -0
- package/src/schema/team-tool-schema.ts +10 -0
- package/src/ui/pi-ui-compat.ts +18 -9
- package/src/ui/widget/index.ts +0 -1
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import type { AgentConfig } from "../../agents/agent-config.ts";
|
|
5
|
+
import { getCrewEnvBool } from "../../config/env-vars.ts";
|
|
5
6
|
import { atomicWriteFile } from "../../state/atomic-write.ts";
|
|
6
7
|
import { hasRunStateLayout, packageRoot, userPiRoot } from "../../utils/paths.ts";
|
|
7
8
|
|
|
@@ -40,6 +41,15 @@ export interface BuildPiWorkerArgsInput {
|
|
|
40
41
|
agent: AgentConfig;
|
|
41
42
|
model?: string;
|
|
42
43
|
sessionEnabled?: boolean;
|
|
44
|
+
/** W2 (session-file recovery): deterministic pi session id for this worker
|
|
45
|
+
* (`--session-id`, SDK ≥ 0.76.0 — below pi-crew's tested host floor 0.99.2,
|
|
46
|
+
* so emitted unconditionally when set; no capability gate). Same id across
|
|
47
|
+
* retries appends to one session file (SDK §R2.2) — cross-attempt resume. */
|
|
48
|
+
sessionId?: string;
|
|
49
|
+
/** W2 (session-file recovery): directory for this worker's session JSONL
|
|
50
|
+
* (`--session-dir`, SDK ≥ 0.30.0). Run-scoped per-worker dir, e.g.
|
|
51
|
+
* `<artifactsRoot>/sessions/<taskId>/` — lives and dies with run artifacts. */
|
|
52
|
+
sessionDir?: string;
|
|
43
53
|
maxDepth?: number;
|
|
44
54
|
skillPaths?: string[];
|
|
45
55
|
env?: NodeJS.ProcessEnv;
|
|
@@ -47,6 +57,24 @@ export interface BuildPiWorkerArgsInput {
|
|
|
47
57
|
role?: string;
|
|
48
58
|
/** Per-role thinking override (teamRole.thinking). Takes precedence over agent.thinking. */
|
|
49
59
|
thinkingOverride?: string;
|
|
60
|
+
/** R3-1 (Pi 1.0.0 deep-learn round 3): run-static worker header (Protocol
|
|
61
|
+
* block, mailbox contract, workspace structure, runtime context) riding the
|
|
62
|
+
* `--append-system-prompt` channel instead of the user-message concatenation.
|
|
63
|
+
* Compaction summarizes the user-message span (compaction.md:150-160) but the
|
|
64
|
+
* system prompt survives intact — the parts that must NEVER be lost move here.
|
|
65
|
+
* Task text + dependency context stay in the user message (they SHOULD be
|
|
66
|
+
* summarizable). Final append order: builtin → agent.systemPrompt (if any,
|
|
67
|
+
* honoring its declared replace/append mode) → this header. Probe-verified
|
|
68
|
+
* 2026-10-04 (/tmp/pi-r3impl): the append channel and AGENTS.md discovery
|
|
69
|
+
* (project_context section) coexist in one system message. */
|
|
70
|
+
systemPromptAppend?: string;
|
|
71
|
+
/** R3-19/D5 (hermetic worker spawns): pass `--no-extensions` on the worker
|
|
72
|
+
* argv. Resolved via resolveHermeticWorkers — env
|
|
73
|
+
* PI_CREW_HERMETIC_WORKERS beats this beats default TRUE. Explicit `-e
|
|
74
|
+
* prompt-runtime` (pushed unconditionally below), agent `extensions:`
|
|
75
|
+
* declarations, and `--skill` flags are unaffected (cli.md:186-191:
|
|
76
|
+
* explicit -e and --skill survive --no-extensions/--no-skills). */
|
|
77
|
+
hermeticWorkers?: boolean;
|
|
50
78
|
}
|
|
51
79
|
|
|
52
80
|
export interface BuildPiWorkerArgsResult {
|
|
@@ -90,6 +118,17 @@ export function resolveCrewMaxDepth(inputMaxDepth?: number, env: NodeJS.ProcessE
|
|
|
90
118
|
return DEFAULT_MAX_CREW_DEPTH;
|
|
91
119
|
}
|
|
92
120
|
|
|
121
|
+
/**
|
|
122
|
+
* R3-19/D5 (hermetic worker spawns): resolution order mirrors
|
|
123
|
+
* resolveSessionRecoveryEnabled — env PI_CREW_HERMETIC_WORKERS beats the
|
|
124
|
+
* explicit config/runtime value beats default TRUE.
|
|
125
|
+
*/
|
|
126
|
+
export function resolveHermeticWorkers(explicit?: boolean): boolean {
|
|
127
|
+
const env = getCrewEnvBool("PI_CREW_HERMETIC_WORKERS");
|
|
128
|
+
if (env !== undefined) return env;
|
|
129
|
+
return explicit ?? true;
|
|
130
|
+
}
|
|
131
|
+
|
|
93
132
|
export function checkCrewDepth(
|
|
94
133
|
inputMaxDepth?: number,
|
|
95
134
|
env: NodeJS.ProcessEnv = process.env,
|
|
@@ -265,6 +304,18 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
|
|
|
265
304
|
// never sets it, so it can never be matched as a sub-agent.
|
|
266
305
|
const args = ["--mode", "json", "-p"];
|
|
267
306
|
if (input.sessionEnabled === false) args.push("--no-session");
|
|
307
|
+
// W2 (session-file recovery): deterministic session identity so a worker
|
|
308
|
+
// that dies without a final assistant event (exitCode null / killed) can
|
|
309
|
+
// have its last COMPLETE assistant turn tail-recovered from the session
|
|
310
|
+
// JSONL (child-pi.ts settle path → session-recovery.ts). Both flags are
|
|
311
|
+
// SDK-era flags older than the tested host floor (fact pack §1: session-id
|
|
312
|
+
// 0.76.0, session-dir 0.30.0 < 0.99.2) → unconditional add, same as the
|
|
313
|
+
// existing --no-session/--model emissions. Skipped when sessions are off —
|
|
314
|
+
// under --no-session pi persists nothing, the flags would be meaningless.
|
|
315
|
+
if (input.sessionEnabled !== false) {
|
|
316
|
+
if (input.sessionId) args.push("--session-id", input.sessionId);
|
|
317
|
+
if (input.sessionDir) args.push("--session-dir", input.sessionDir);
|
|
318
|
+
}
|
|
268
319
|
|
|
269
320
|
const resolvedModel = input.model ?? input.agent.model;
|
|
270
321
|
// H1.a: teamRole.thinking (passed as thinkingOverride) takes precedence over agent.thinking.
|
|
@@ -278,8 +329,13 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
|
|
|
278
329
|
args.push("--thinking", effectiveThinking);
|
|
279
330
|
}
|
|
280
331
|
|
|
281
|
-
// D5 (spec v0.7 §6): default loadout = FULL session (như main session)
|
|
282
|
-
// --no-
|
|
332
|
+
// D5 (spec v0.7 §6): default loadout = FULL session (như main session) —
|
|
333
|
+
// --no-skills/--tools CHỈ xuất hiện khi agent .md khai explicit. R3-19/D5
|
|
334
|
+
// reversal (2026-10-04): extension discovery is now hermetic by default
|
|
335
|
+
// (--no-extensions below, off-switch runtime.hermeticWorkers / env
|
|
336
|
+
// PI_CREW_HERMETIC_WORKERS) — the ambient package stack cost ~1.37s/spawn
|
|
337
|
+
// and leaked 8 direct-exposure MCP tools + 5 host tools into every worker
|
|
338
|
+
// (probe C §R3b.6: 431ms vs 1806ms to first stdout record).
|
|
283
339
|
// "message" MUST stay in CONTROL_TOOLS: it is registered by prompt-runtime
|
|
284
340
|
// behind the PI_CREW_MSG_ENABLED dormant-env gate (child-pi-spawn.ts sets it
|
|
285
341
|
// unconditionally), but pi's --tools allowlist filters the tool SURFACE — a
|
|
@@ -332,7 +388,13 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
|
|
|
332
388
|
const excluded = new Set((input.agent.excludeExtensions ?? []).map((name) => path.basename(name).toLowerCase()));
|
|
333
389
|
declaredExtensions = declaredExtensions.filter((ext) => !excluded.has(path.basename(ext).toLowerCase()));
|
|
334
390
|
for (const ext of declaredExtensions) args.push("--extension", ext);
|
|
335
|
-
//
|
|
391
|
+
// R3-19/D5: hermetic spawns — disable discovered/configured/built-in
|
|
392
|
+
// extensions on worker argv. cli.md:186-187: "Explicit -e paths still
|
|
393
|
+
// load" — the unconditional prompt-runtime push above and the agent
|
|
394
|
+
// `extensions:` declarations below survive; --skill flags are a separate
|
|
395
|
+
// gate. Default ON (resolveHermeticWorkers); surface TUI panes strip the
|
|
396
|
+
// flag (child-pi.ts stripHeadlessOnlyFlags).
|
|
397
|
+
if (resolveHermeticWorkers(input.hermeticWorkers)) args.push("--no-extensions");
|
|
336
398
|
if (input.agent.inheritSkills === false) args.push("--no-skills");
|
|
337
399
|
for (const skillPath of input.skillPaths ?? []) args.push("--skill", skillPath);
|
|
338
400
|
|
|
@@ -346,6 +408,45 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
|
|
|
346
408
|
atomicWriteFile(promptPath, input.agent.systemPrompt, { mode: 0o600 });
|
|
347
409
|
args.push(input.agent.systemPromptMode === "append" ? "--append-system-prompt" : "--system-prompt", promptPath);
|
|
348
410
|
}
|
|
411
|
+
// R3-1: run-static header rides its own --append-system-prompt file. pi
|
|
412
|
+
// parses --system-prompt and --append-system-prompt independently and the
|
|
413
|
+
// append flag is repeatable (cli/args.js:71-77; appends join with "\n\n" in
|
|
414
|
+
// argv order), so pushing it AFTER the agent flag yields the required final
|
|
415
|
+
// order builtin → agent.systemPrompt → run-static header in BOTH modes
|
|
416
|
+
// (replace mode: agent file replaces builtin, header appends after it;
|
|
417
|
+
// append mode: two append files in argv order).
|
|
418
|
+
if (input.systemPromptAppend?.trim()) {
|
|
419
|
+
if (!tempDir) {
|
|
420
|
+
const tmpBase = getPiTempBase();
|
|
421
|
+
tempDir = createSafeTempDir(tmpBase, `pi-crew-${process.pid}-`);
|
|
422
|
+
}
|
|
423
|
+
const headerPath = path.join(tempDir, "worker-header.md");
|
|
424
|
+
atomicWriteFile(headerPath, input.systemPromptAppend, { mode: 0o600 });
|
|
425
|
+
args.push("--append-system-prompt", headerPath);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// D1 (R3-3, pinned 2026-10-04): deterministic project-trust for worker
|
|
429
|
+
// spawns — `--no-approve` (cli alias -na). The command-line override applies
|
|
430
|
+
// FIRST in pi's trust resolution (security.md "How Pi chooses a trust
|
|
431
|
+
// decision"), before any extension, saved decision in ~/.pi/agent/trust.json,
|
|
432
|
+
// or defaultProjectTrust — so worker behavior no longer varies with the
|
|
433
|
+
// ambient trust store of the machine (P-C probe: /home/bom/source/my_pi and
|
|
434
|
+
// /tmp were pre-trusted there). Decision rationale vs the D1 criteria:
|
|
435
|
+
// (a) no ambient-trust dependency ✓ (CLI override wins);
|
|
436
|
+
// (b) write capability intact ✓ (security.md:33 — project trust does NOT
|
|
437
|
+
// limit what tool calls can access; only startup resource loading is
|
|
438
|
+
// gated, so workers still write files in the task cwd);
|
|
439
|
+
// (c) minimal scope ✓ (declines rather than grants; one command; not
|
|
440
|
+
// persisted to trust.json).
|
|
441
|
+
// AGENTS.md/CLAUDE.md context files load REGARDLESS of project trust
|
|
442
|
+
// (security.md:57) — probe-verified 2026-10-04 (/tmp/pi-r3impl probe2: the
|
|
443
|
+
// project_context section is present with --no-approve), so the R3-18
|
|
444
|
+
// repo-instruction auto-load is preserved. `-a` was rejected: it grants
|
|
445
|
+
// maximal project trust (loads .pi/mcp.json, .pi/extensions… from untrusted
|
|
446
|
+
// task cwds) — the opposite of minimal scope, and redundant with the D5
|
|
447
|
+
// hermetic posture. Surface TUI spawns strip this flag (child-pi.ts
|
|
448
|
+
// trySurfaceBranch) to keep the interactive trust prompt available.
|
|
449
|
+
args.push("--no-approve");
|
|
349
450
|
|
|
350
451
|
// G3 (SDD-2 W-B, spill-always): task text NEVER rides argv — argv is
|
|
351
452
|
// world-readable via /proc/<pid>/cmdline, leaking task content (and any
|
package/src/runtime/peer-dep.ts
CHANGED
|
@@ -69,6 +69,9 @@ let cachedResolve: ResolvedPeerDep | undefined | null = null;
|
|
|
69
69
|
let cachedModule: PeerDepModule | undefined;
|
|
70
70
|
let primingPromise: Promise<PeerDepModule> | undefined;
|
|
71
71
|
|
|
72
|
+
/** Warn-once guard for the legacy-fork resolution notice (W6/P2-4, 2026-10-03). */
|
|
73
|
+
let forkResolutionWarned = false;
|
|
74
|
+
|
|
72
75
|
/**
|
|
73
76
|
* Build the ordered list of "resolution bases" — paths to seed
|
|
74
77
|
* `createRequire(...).resolve()` from. Node walks UP `node_modules` from each
|
|
@@ -212,6 +215,24 @@ function tryResolveFrom(base: string): ResolvedPeerDep | undefined {
|
|
|
212
215
|
}
|
|
213
216
|
}
|
|
214
217
|
|
|
218
|
+
/**
|
|
219
|
+
* W6/P2-4 (pi 1.0.0 adoption wave, 2026-10-03): the @mariozechner fork stopped
|
|
220
|
+
* publishing (npm latest 0.73.1, 2026-05-07) and sits outside the
|
|
221
|
+
* @earendil-works semver guarantees, but resolution code + tests still
|
|
222
|
+
* deliberately support it (Windows global installs, issue #33) and there is
|
|
223
|
+
* no install-base evidence it is unused — kept, not removed. Warn once per
|
|
224
|
+
* process so installs relying on it know to migrate to the canonical package.
|
|
225
|
+
*/
|
|
226
|
+
function warnIfForkResolved(name: string): void {
|
|
227
|
+
if (name !== "@mariozechner/pi-coding-agent" || forkResolutionWarned) return;
|
|
228
|
+
forkResolutionWarned = true;
|
|
229
|
+
console.warn(
|
|
230
|
+
`[pi-crew] notice: resolved the legacy peer dependency "${name}" (fork last published 0.73.1, 2026-05-07). ` +
|
|
231
|
+
`The canonical package is "@earendil-works/pi-coding-agent"; pi-crew keeps the fork for compatibility, ` +
|
|
232
|
+
`but support may be removed in a future release.`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
215
236
|
/** Resolve the peer dep install dir + ESM entry URL. Memoized (sync). */
|
|
216
237
|
export function resolvePeerDep(): ResolvedPeerDep | undefined {
|
|
217
238
|
if (cachedResolve !== null) return cachedResolve ?? undefined;
|
|
@@ -225,6 +246,7 @@ export function resolvePeerDep(): ResolvedPeerDep | undefined {
|
|
|
225
246
|
for (const base of bases) {
|
|
226
247
|
const found = tryResolveFrom(base);
|
|
227
248
|
if (found) {
|
|
249
|
+
warnIfForkResolved(found.name);
|
|
228
250
|
cachedResolve = found;
|
|
229
251
|
return found;
|
|
230
252
|
}
|
|
@@ -234,6 +256,7 @@ export function resolvePeerDep(): ResolvedPeerDep | undefined {
|
|
|
234
256
|
for (const base of peerDepNpmGlobalBases()) {
|
|
235
257
|
const found = tryResolveFrom(base);
|
|
236
258
|
if (found) {
|
|
259
|
+
warnIfForkResolved(found.name);
|
|
237
260
|
cachedResolve = found;
|
|
238
261
|
return found;
|
|
239
262
|
}
|
|
@@ -317,4 +340,5 @@ export function __resetPeerDepCacheForTest(): void {
|
|
|
317
340
|
cachedResolve = null;
|
|
318
341
|
cachedModule = undefined;
|
|
319
342
|
primingPromise = undefined;
|
|
343
|
+
forkResolutionWarned = false;
|
|
320
344
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# RPC worker transport — W7 (P2-3) EXPERIMENTAL PROTOTYPE
|
|
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).
|
|
6
|
+
|
|
7
|
+
## What this is
|
|
8
|
+
|
|
9
|
+
A JSONL frame client that speaks the pi SDK's RPC mode protocol
|
|
10
|
+
(`pi --mode rpc`) on stdin/stdout, implementing the two Round-2 design gates
|
|
11
|
+
the adoption review (§R2.5) proved are mandatory for any RPC transport:
|
|
12
|
+
|
|
13
|
+
1. **`extension_ui_request` drain policy** (`ui-request-policy.ts`) —
|
|
14
|
+
fire-and-forget methods (`notify`, `setStatus`, `setWidget`, `setTitle`,
|
|
15
|
+
`set_editor_text`) are counted per method and dropped. Round-2 live
|
|
16
|
+
evidence: 15 of the first 16 records a worker emits belong to this family.
|
|
17
|
+
2. **Dialog auto-answer policy** (`cancel` | `block`) — dialog methods
|
|
18
|
+
(`select`/`confirm`/`input`/`editor`) park a server-side promise that NEVER
|
|
19
|
+
resolves if unanswered (ask-over-RPC deadlock, R2.5). `cancel` (default)
|
|
20
|
+
answers the universal safe answer `{type:"extension_ui_response", id,
|
|
21
|
+
cancelled:true}` (select/input → undefined, confirm → false). `block`
|
|
22
|
+
leaves it pending on purpose (debug). **There is no auto-confirm mode —
|
|
23
|
+
deliberate security decision.** An unattended transport must never say
|
|
24
|
+
"yes" for the user.
|
|
25
|
+
|
|
26
|
+
Counters (fake-stream asserted): `uiRequestsDrained`, `drainedByMethod`,
|
|
27
|
+
`dialogsCancelled`, `dialogsBlocked`, `malformedFrames`, `responsesMatched`,
|
|
28
|
+
`responsesUnmatched`.
|
|
29
|
+
|
|
30
|
+
## Deviation from "use SDK RpcClient" — recorded, evidence-based
|
|
31
|
+
|
|
32
|
+
The task's original phrasing said "transport dùng SDK RpcClient/runRpcMode".
|
|
33
|
+
The verified recon packet (adaptive-07) proved `RpcClient` unusable for this
|
|
34
|
+
prototype and the leader routed the packet through; deviation accepted per
|
|
35
|
+
packet §C:
|
|
36
|
+
|
|
37
|
+
1. **No public API to answer `extension_ui_request`** — `send`/`process` are
|
|
38
|
+
private; non-response records go to event listeners only. Gate 2 would
|
|
39
|
+
require bracket-accessing private members.
|
|
40
|
+
2. **Version skew** — `RpcClient.start()` spawns `node <cliPath ?? "dist/cli.js">`
|
|
41
|
+
cwd-relative, NOT the host pi binary the stdio transport resolves
|
|
42
|
+
(`getPiSpawnCommand` / `PI_TEAMS_PI_BIN`).
|
|
43
|
+
3. **Not injectable** — the W7 test requirement ("fake RPC stream, no real
|
|
44
|
+
spawn") is impossible against `RpcClient`.
|
|
45
|
+
|
|
46
|
+
`runRpcMode` is a server-side embed API — inapplicable (pi-crew is a spawner,
|
|
47
|
+
not an embedder).
|
|
48
|
+
|
|
49
|
+
So: **own strict-LF frame loop over the SDK's exported protocol TYPES**
|
|
50
|
+
(`RpcCommand` / `RpcResponse` / `RpcExtensionUIRequest` /
|
|
51
|
+
`RpcExtensionUIResponse` — all imported from `@earendil-works/pi-coding-agent`).
|
|
52
|
+
The framing rule follows the SDK's own `modes/rpc/jsonl.d.ts` spec: split on
|
|
53
|
+
`\n` ONLY. **Node readline is unsafe here** — it also splits on U+2028/U+2029,
|
|
54
|
+
which are legal inside JSON strings. `serializeJsonLine` /
|
|
55
|
+
`attachJsonlLineReader` are not exported top-level, hence the ~15-line
|
|
56
|
+
hand-rolled splitter.
|
|
57
|
+
|
|
58
|
+
## Config & env
|
|
59
|
+
|
|
60
|
+
- `runtime.workerTransport: "stdio" (default) | "rpc"` — declared in
|
|
61
|
+
`src/schema/config-schema.ts` + `src/config/types.ts`. **Config→seam plumbing
|
|
62
|
+
is NOT wired yet** (needs a `parseRuntimeConfig` line in
|
|
63
|
+
`config-validation.ts`, which is outside this lane's file ownership);
|
|
64
|
+
the live gate is env `PI_CREW_WORKER_TRANSPORT` (`runWorker` has no config
|
|
65
|
+
access — packet §A).
|
|
66
|
+
- `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
|
+
- `PI_CREW_RPC_DIALOG_ANSWER=cancel|block` — GATE 2 policy (default `cancel`,
|
|
70
|
+
invalid fails safe to `cancel`).
|
|
71
|
+
|
|
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).
|
|
87
|
+
|
|
88
|
+
## Files
|
|
89
|
+
|
|
90
|
+
- `frame-client.ts` — strict-LF JSONL frame client, pending-command map with
|
|
91
|
+
per-command timeout, malformed-frame resilience (count + drop, never throw),
|
|
92
|
+
orderly stop (stdin end → exit → SIGKILL fallback, kill-once guard).
|
|
93
|
+
- `ui-request-policy.ts` — the two design gates + counters.
|
|
94
|
+
- `rpc-worker.ts` — env resolvers (`resolveWorkerTransport`,
|
|
95
|
+
`resolveDialogAnswerPolicy`) + `runRpcWorker` prototype runner.
|
|
96
|
+
|
|
97
|
+
## Production-ready? Honestly: NO.
|
|
98
|
+
|
|
99
|
+
Prototype-grade. Validated only against fake streams
|
|
100
|
+
(`test/unit/runtime/rpc/`). No live spawn has been attempted from this code.
|
|
@@ -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
|
+
}
|