pi-crew 0.11.5 → 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.
Files changed (68) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +1 -1
  3. package/dist/index.mjs +503 -493
  4. package/docs/dynamic-workflows.md +37 -0
  5. package/package.json +13 -9
  6. package/scripts/analyze-run.mjs +95 -11
  7. package/skills/model-routing-context/SKILL.md +4 -3
  8. package/skills/real-test-pi-crew/SKILL.md +9 -6
  9. package/src/config/config-validation.ts +28 -0
  10. package/src/config/env-vars.ts +29 -0
  11. package/src/config/types.ts +47 -0
  12. package/src/extension/crew-vibes/index.ts +29 -10
  13. package/src/extension/pi-api.ts +1 -0
  14. package/src/extension/registration/lifecycle-handlers.ts +65 -3
  15. package/src/extension/team-tool/respond.ts +54 -0
  16. package/src/extension/team-tool/status.ts +10 -0
  17. package/src/extension/team-tool.ts +393 -3
  18. package/src/extension/webhook-notify.ts +23 -1
  19. package/src/observability/exporters/otlp-exporter.ts +93 -11
  20. package/src/prompt/prompt-runtime.ts +46 -8
  21. package/src/runtime/agent-control.ts +1 -15
  22. package/src/runtime/background-runner.ts +3 -0
  23. package/src/runtime/broker/crew-broker.ts +12 -103
  24. package/src/runtime/broker/protocol/request-parsers.ts +9 -1
  25. package/src/runtime/broker/protocol/wait-resolve.ts +168 -0
  26. package/src/runtime/child-pi/child-pi-spawn.ts +12 -4
  27. package/src/runtime/child-pi/child-pi.ts +93 -4
  28. package/src/runtime/child-pi/session-recovery.ts +334 -0
  29. package/src/runtime/dispatch-batch.ts +3 -1
  30. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +111 -12
  31. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +19 -0
  32. package/src/runtime/live-session/live-agent-manager.ts +35 -0
  33. package/src/runtime/live-session/live-session-runtime.ts +34 -5
  34. package/src/runtime/mcp-proxy.ts +110 -7
  35. package/src/runtime/model/model-scope.ts +141 -21
  36. package/src/runtime/model/pi-args.ts +115 -11
  37. package/src/runtime/model/provider-extensions.ts +67 -1
  38. package/src/runtime/peer-dep.ts +24 -0
  39. package/src/runtime/rpc/README.md +100 -0
  40. package/src/runtime/rpc/frame-client.ts +328 -0
  41. package/src/runtime/rpc/rpc-worker.ts +151 -0
  42. package/src/runtime/rpc/ui-request-policy.ts +112 -0
  43. package/src/runtime/run-worker.ts +14 -0
  44. package/src/runtime/scheduling/run-coalesced-task-group.ts +89 -6
  45. package/src/runtime/stale-reconciler.ts +47 -0
  46. package/src/runtime/surface/surface-spawn.ts +8 -1
  47. package/src/runtime/task-output-context.ts +191 -32
  48. package/src/runtime/task-packet.ts +3 -0
  49. package/src/runtime/task-runner/child-executor.ts +170 -6
  50. package/src/runtime/task-runner/output-splitter.ts +48 -5
  51. package/src/runtime/task-runner/post-execution.ts +5 -5
  52. package/src/runtime/task-runner/pre-execution.ts +32 -4
  53. package/src/runtime/task-runner/prompt-builder.ts +10 -2
  54. package/src/runtime/task-runner/retrieval-orchestrator.ts +28 -2
  55. package/src/schema/config-schema.ts +25 -0
  56. package/src/schema/team-tool-schema.ts +35 -0
  57. package/src/state/contracts.ts +16 -0
  58. package/src/state/crew-init.ts +114 -0
  59. package/src/state/stores/active-run-registry.ts +13 -6
  60. package/src/state/types.ts +0 -8
  61. package/src/ui/goal-flag.ts +44 -0
  62. package/src/ui/live-run-sidebar.ts +5 -1
  63. package/src/ui/pi-ui-compat.ts +37 -11
  64. package/src/ui/run-dashboard.ts +2 -1
  65. package/src/ui/widget/index.ts +0 -1
  66. package/src/ui/widget/widget-model.ts +6 -2
  67. package/src/utils/ip-literal.ts +145 -0
  68. package/src/workflows/workflow-config.ts +6 -0
@@ -11,11 +11,34 @@
11
11
  * - defaultSubagentModel / parentModel-inherited out-of-scope
12
12
  * → WARNING + runs anyway (soft warn, same as frontmatter).
13
13
  *
14
- * Pattern semantics match pi's `--models` CLI / `enabledModels` allowlist:
15
- * - `"anthropic/claude-opus-4-5"` — exact match (case-insensitive).
16
- * - `"claude-*"`, `"*sonnet*"`, `"github-copilot/*"` — glob (single `*`).
17
- * - Any other string — case-insensitive substring fallback (pi's
18
- * `tryMatchModel` behavior, model-resolver.ts).
14
+ * Pattern semantics mirror pi 1.0.0's canonical `resolveModelScopeFromModels()`
15
+ * (SDK `core/model-resolver.js:204-270`) and `parseModelPattern()` (:155-200) —
16
+ * R3-20 alignment (docs/reviews/pi-1.0.0-deep-learn-r3-2026-10-03.md §R3b.3):
17
+ * - Optional `:<thinking>` suffix (`off|minimal|low|medium|high|xhigh|max`,
18
+ * case-sensitive) is stripped from patterns AND resolved model ids before
19
+ * matching, so `anthropic/claude-sonnet-5:high` in enabledModels no longer
20
+ * falsely rejects the plain model id (hard-error bug).
21
+ * - A pattern containing ANY of `*`, `?`, `[` is a glob, translated with
22
+ * minimatch semantics (`*`/`?` do not cross `/`, `[...]`/`[!...]` classes,
23
+ * whole-segment `**` crosses separators), anchored + case-insensitive.
24
+ * - Globs are tried against BOTH the configured `provider/id` form and the
25
+ * bare model id (after the first `/`), mirroring
26
+ * `minimatch(fullId, p) || minimatch(id, p)` — so `gpt-5*` matches
27
+ * `openai/gpt-5-mini`.
28
+ * - Non-glob patterns fall back to case-insensitive substring, progressively
29
+ * stripping trailing colon suffixes (mirrors parseModelPattern's
30
+ * recursion: valid levels become thinking hints, invalid ones are
31
+ * recursed through with a warning — membership still follows the prefix).
32
+ *
33
+ * Why reimplement instead of importing the canonical matcher: the SDK does
34
+ * NOT export `resolveModelScopeFromModels` from the package root (runtime-
35
+ * verified: only `resolveModelScopeWithDiagnostics` is exported, and it needs
36
+ * a live async `ModelRuntime` + catalog I/O). This gate is synchronous and
37
+ * this module is pure by design, so an exact-semantics reimplementation is
38
+ * the only import-compatible option. Residual divergence (documented, not
39
+ * fixable string-level): canonical fuzzy also matches model display `name`s
40
+ * (`model-resolver.js:114-115`), which requires the catalog — display-name-
41
+ * only patterns (e.g. `"Claude Opus 4.5"`) stay unmatched here.
19
42
  *
20
43
  * This module is pure (no I/O, no globals). Reading the actual
21
44
  * `enabledModels` from pi's settings is the caller's job (instantiate
@@ -41,37 +64,134 @@ export interface ModelScopeCheck {
41
64
  }
42
65
 
43
66
  /**
44
- * Convert a glob pattern with `*` wildcards into a RegExp.
45
- * Escape all regex meta-characters except `*`, which becomes `.*`.
46
- * Anchored (^...$) and case-insensitive.
67
+ * Valid `:<thinking>` level names — mirrors pi's `VALID_THINKING_LEVELS`
68
+ * (SDK `cli/args.js`). Matched case-sensitively, exactly like the canonical
69
+ * `isValidThinkingLevel()`, so OpenRouter-style ids (`foo:exacto`) and
70
+ * mistyped levels (`:HIGH`) are NOT treated as thinking suffixes.
71
+ */
72
+ const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
73
+
74
+ function isValidThinkingLevel(level: string): boolean {
75
+ return (VALID_THINKING_LEVELS as readonly string[]).includes(level);
76
+ }
77
+
78
+ /**
79
+ * Strip one trailing VALID `:<thinking>` suffix — the canonical glob-branch
80
+ * rule (model-resolver.js:214-222). Invalid suffixes are left in place here;
81
+ * the fuzzy branch strips them progressively instead (parseModelPattern
82
+ * recursion) — see matchesModelPattern.
83
+ */
84
+ function stripValidThinkingSuffix(value: string): string {
85
+ const colonIdx = value.lastIndexOf(":");
86
+ if (colonIdx === -1) return value;
87
+ return isValidThinkingLevel(value.substring(colonIdx + 1)) ? value.substring(0, colonIdx) : value;
88
+ }
89
+
90
+ /** Does the pattern contain glob characters? Canonical test: `*`, `?` or `[`. */
91
+ function hasGlobChars(pattern: string): boolean {
92
+ return pattern.includes("*") || pattern.includes("?") || pattern.includes("[");
93
+ }
94
+
95
+ /** Escape a literal character for use in a RegExp body. */
96
+ function escapeRegExpChar(ch: string): string {
97
+ return /[.+^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
98
+ }
99
+
100
+ /**
101
+ * Convert a glob pattern into an anchored, case-insensitive RegExp with
102
+ * minimatch semantics (canonical uses `minimatch(p, { nocase: true })`):
103
+ * - `*` → `[^/]*` (any run of non-separator chars; does NOT cross `/`)
104
+ * - `?` → `[^/]` (exactly one non-separator char)
105
+ * - `[...]` / `[!...]` / `[^...]` → character class (both `!` and `^` negate)
106
+ * - `[` without a closing `]` → literal `[`
107
+ * - a whole-segment run of `**` → `.*` (crosses separators, but still
108
+ * requires the preceding `/` — `anthropic/**` does not match `anthropic`)
109
+ * - everything else → regex-escaped literal
47
110
  */
48
111
  export function patternToRegExp(pattern: string): RegExp {
49
- const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
50
- return new RegExp(`${escaped.replace(/\*/g, ".*")}`, "i");
112
+ let out = "^";
113
+ for (let i = 0; i < pattern.length; i++) {
114
+ const ch = pattern[i];
115
+ if (ch === "*") {
116
+ let end = i;
117
+ while (end < pattern.length && pattern[end] === "*") end++;
118
+ const wholeSegment = (i === 0 || pattern[i - 1] === "/") && (end === pattern.length || pattern[end] === "/");
119
+ out += end - i >= 2 && wholeSegment ? ".*" : "[^/]*";
120
+ i = end - 1;
121
+ } else if (ch === "?") {
122
+ out += "[^/]";
123
+ } else if (ch === "[") {
124
+ const closing = pattern.indexOf("]", i + 1);
125
+ let inner = closing === -1 ? "" : pattern.substring(i + 1, closing);
126
+ let negated = false;
127
+ if (inner.startsWith("!") || inner.startsWith("^")) {
128
+ negated = true;
129
+ inner = inner.substring(1);
130
+ }
131
+ if (closing === -1 || inner.length === 0) {
132
+ // Unmatched `[` (or empty class `[]`/`[!]`) → literal, like minimatch.
133
+ out += "\\[";
134
+ } else {
135
+ let cls = "";
136
+ for (const c of inner) {
137
+ if (c === "\\") cls += "\\\\";
138
+ else if (c === "]") cls += "\\]";
139
+ else cls += c;
140
+ }
141
+ out += `[${negated ? "^" : ""}${cls}]`;
142
+ i = closing;
143
+ }
144
+ } else {
145
+ out += escapeRegExpChar(ch);
146
+ }
147
+ }
148
+ return new RegExp(`${out}$`, "i");
51
149
  }
52
150
 
53
151
  /**
54
152
  * Does a model id match a single allowlist pattern?
55
- * Semantics (in order):
56
- * 1. Exact case-insensitive match.
57
- * 2. Glob match (pattern contains `*`).
58
- * 3. Case-insensitive substring match (pi's fallback).
153
+ * Mirrors the canonical `resolveModelScopeFromModels()` per-pattern flow:
154
+ * - Glob branch (pattern contains `*`, `?` or `[`): strip one VALID
155
+ * `:<thinking>` suffix, try the exact reference first, then match the
156
+ * glob against BOTH the configured string and its bare-id form
157
+ * (`minimatch(fullId, p) || minimatch(id, p)`, model-resolver.js:228-232).
158
+ * - Fuzzy branch (parseModelPattern): substring match, progressively
159
+ * stripping trailing colon suffixes until a hit or no colon remains.
59
160
  * Returns true on first hit; false otherwise.
60
161
  */
61
162
  export function matchesModelPattern(modelId: string, pattern: string): boolean {
62
163
  if (!modelId || !pattern) return false;
63
- const id = modelId.trim();
64
- const pat = pattern.trim();
65
- if (!id || !pat) return false;
66
- if (id.toLowerCase() === pat.toLowerCase()) return true;
67
- if (pat.includes("*")) {
164
+ const rawId = modelId.trim();
165
+ const rawPat = pattern.trim();
166
+ if (!rawId || !rawPat) return false;
167
+ // Canonical compares thinking-less base ids: pi-crew's resolved candidates
168
+ // keep their `:high` suffix (splitThinkingSuffix), and user patterns may
169
+ // carry one — strip valid suffixes from BOTH sides before matching.
170
+ const id = stripValidThinkingSuffix(rawId);
171
+ // Bare-id form = everything after the FIRST `/` (the provider segment);
172
+ // openrouter-style ids can themselves contain slashes.
173
+ const bareId = id.includes("/") ? id.substring(id.indexOf("/") + 1) : id;
174
+ if (hasGlobChars(rawPat)) {
175
+ const glob = stripValidThinkingSuffix(rawPat);
176
+ if (id.toLowerCase() === glob.toLowerCase() || bareId.toLowerCase() === glob.toLowerCase()) {
177
+ return true;
178
+ }
68
179
  try {
69
- return patternToRegExp(pat).test(id);
180
+ const re = patternToRegExp(glob);
181
+ return re.test(id) || re.test(bareId);
70
182
  } catch {
71
183
  return false;
72
184
  }
73
185
  }
74
- return id.toLowerCase().includes(pat.toLowerCase());
186
+ let pat = rawPat;
187
+ for (;;) {
188
+ // Substring over the configured form is a superset of canonical's
189
+ // exact-then-substring over the same string, so verdicts agree.
190
+ if (id.toLowerCase().includes(pat.toLowerCase())) return true;
191
+ const colonIdx = pat.lastIndexOf(":");
192
+ if (colonIdx === -1) return false;
193
+ pat = pat.substring(0, colonIdx);
194
+ }
75
195
  }
76
196
 
77
197
  /**
@@ -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
 
@@ -15,7 +16,6 @@ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
15
16
  // find pi-crew's package.json and works correctly from both src/ and
16
17
  // dist/ entry points.
17
18
  const PROMPT_RUNTIME_EXTENSION_PATH = path.join(packageRoot(), "src", "prompt", "prompt-runtime.ts");
18
- const TASK_ARG_LIMIT = 8000;
19
19
  // D8 spec v0.7 — nested mở (default 4 depth levels: child→child→child→child);
20
20
  // cap giữ để chống runaway recursion từ config lỗi hoặc intentional abuse.
21
21
  const DEFAULT_MAX_CREW_DEPTH = 4;
@@ -41,6 +41,15 @@ export interface BuildPiWorkerArgsInput {
41
41
  agent: AgentConfig;
42
42
  model?: string;
43
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;
44
53
  maxDepth?: number;
45
54
  skillPaths?: string[];
46
55
  env?: NodeJS.ProcessEnv;
@@ -48,6 +57,24 @@ export interface BuildPiWorkerArgsInput {
48
57
  role?: string;
49
58
  /** Per-role thinking override (teamRole.thinking). Takes precedence over agent.thinking. */
50
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;
51
78
  }
52
79
 
53
80
  export interface BuildPiWorkerArgsResult {
@@ -91,6 +118,17 @@ export function resolveCrewMaxDepth(inputMaxDepth?: number, env: NodeJS.ProcessE
91
118
  return DEFAULT_MAX_CREW_DEPTH;
92
119
  }
93
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
+
94
132
  export function checkCrewDepth(
95
133
  inputMaxDepth?: number,
96
134
  env: NodeJS.ProcessEnv = process.env,
@@ -266,6 +304,18 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
266
304
  // never sets it, so it can never be matched as a sub-agent.
267
305
  const args = ["--mode", "json", "-p"];
268
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
+ }
269
319
 
270
320
  const resolvedModel = input.model ?? input.agent.model;
271
321
  // H1.a: teamRole.thinking (passed as thinkingOverride) takes precedence over agent.thinking.
@@ -279,8 +329,13 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
279
329
  args.push("--thinking", effectiveThinking);
280
330
  }
281
331
 
282
- // D5 (spec v0.7 §6): default loadout = FULL session (như main session).
283
- // --no-extensions/--no-skills/--tools CHỈ xuất hiện khi agent .md khai explicit.
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).
284
339
  // "message" MUST stay in CONTROL_TOOLS: it is registered by prompt-runtime
285
340
  // behind the PI_CREW_MSG_ENABLED dormant-env gate (child-pi-spawn.ts sets it
286
341
  // unconditionally), but pi's --tools allowlist filters the tool SURFACE — a
@@ -333,7 +388,13 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
333
388
  const excluded = new Set((input.agent.excludeExtensions ?? []).map((name) => path.basename(name).toLowerCase()));
334
389
  declaredExtensions = declaredExtensions.filter((ext) => !excluded.has(path.basename(ext).toLowerCase()));
335
390
  for (const ext of declaredExtensions) args.push("--extension", ext);
336
- // KHÔNG còn --no-extensions (extension discovery hoạt động như main session).
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");
337
398
  if (input.agent.inheritSkills === false) args.push("--no-skills");
338
399
  for (const skillPath of input.skillPaths ?? []) args.push("--skill", skillPath);
339
400
 
@@ -347,18 +408,61 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
347
408
  atomicWriteFile(promptPath, input.agent.systemPrompt, { mode: 0o600 });
348
409
  args.push(input.agent.systemPromptMode === "append" ? "--append-system-prompt" : "--system-prompt", promptPath);
349
410
  }
350
-
351
- if (input.task.length > TASK_ARG_LIMIT) {
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()) {
352
419
  if (!tempDir) {
353
420
  const tmpBase = getPiTempBase();
354
421
  tempDir = createSafeTempDir(tmpBase, `pi-crew-${process.pid}-`);
355
422
  }
356
- const taskPath = path.join(tempDir, "task.md");
357
- atomicWriteFile(taskPath, input.task, { mode: 0o600 });
358
- args.push(`@${taskPath}`);
359
- } else {
360
- args.push(`Task: ${input.task}`);
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");
450
+
451
+ // G3 (SDD-2 W-B, spill-always): task text NEVER rides argv — argv is
452
+ // world-readable via /proc/<pid>/cmdline, leaking task content (and any
453
+ // secrets embedded in prompts) to every local user/process scanner.
454
+ // EVERY task, regardless of length, is spilled to an owner-only (0600)
455
+ // task.md inside pi-crew's temp dir and passed as a `@<path>` inclusion
456
+ // arg. The pre-G3 short-argv branch (task <= 8000 → `Task: ...` positional)
457
+ // is removed; the file content is the RAW task text (same contract the
458
+ // long-task path always had — no `Task: ` prefix).
459
+ if (!tempDir) {
460
+ const tmpBase = getPiTempBase();
461
+ tempDir = createSafeTempDir(tmpBase, `pi-crew-${process.pid}-`);
361
462
  }
463
+ const taskPath = path.join(tempDir, "task.md");
464
+ atomicWriteFile(taskPath, input.task, { mode: 0o600 });
465
+ args.push(`@${taskPath}`);
362
466
 
363
467
  const env = input.env ?? process.env;
364
468
  const parentDepth = currentCrewDepth(env);
@@ -62,6 +62,61 @@ function cachedResult(settingsPath: string): { mtimeMs: number; result: Discover
62
62
  return undefined;
63
63
  }
64
64
 
65
+ /**
66
+ * GH #61: normalize a DIRECTORY `pi.extensions` entry to the entry FILE that
67
+ * Pi's own package auto-load registers. Pi's chain (package-manager
68
+ * `collectAutoExtensionEntries` → `resolveExtensionEntries`) resolves a
69
+ * directory to its nested pi-manifest entries, else `index.ts`/`index.js`.
70
+ * If pi-crew injects the directory as-is, the child worker loads the same
71
+ * extension twice under two path identities (".../dist" via --extension vs
72
+ * ".../dist/index.js" via package auto-discovery) and pi's
73
+ * detectExtensionConflicts kills the worker at startup (exit 1, every tool
74
+ * reported as conflicting). Always inject the FILE identity.
75
+ */
76
+ function resolveExtensionDirEntry(dir: string, depth = 0): string | undefined {
77
+ if (depth > 2) return undefined; // guard pathological nesting loops
78
+ const pkgJsonPath = path.join(dir, "package.json");
79
+ if (fs.existsSync(pkgJsonPath)) {
80
+ try {
81
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")) as { pi?: { extensions?: string[] } };
82
+ const piExts = pkg.pi?.extensions;
83
+ if (Array.isArray(piExts) && piExts.length > 0) {
84
+ for (const rel of piExts) {
85
+ const abs = path.resolve(dir, rel);
86
+ if (!fs.existsSync(abs)) continue;
87
+ return fs.statSync(abs).isDirectory() ? resolveExtensionDirEntry(abs, depth + 1) : abs;
88
+ }
89
+ }
90
+ } catch {
91
+ /* malformed package.json — fall through to index probes */
92
+ }
93
+ }
94
+ // Order mirrors Pi's resolveExtensionEntries EXACTLY for the first two
95
+ // (index.ts → index.js): when Pi's auto-load registers the extension, the
96
+ // injected identity MUST match its pick. index.mjs and package.json `main`
97
+ // are fallbacks for packages Pi's auto-load would NOT register at all (it
98
+ // checks neither) — a single injected instance there cannot conflict, and
99
+ // `main` preserves the legacy jiti dir-loading behaviour (GH #61 follow-up).
100
+ for (const name of ["index.ts", "index.js"]) {
101
+ const abs = path.join(dir, name);
102
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return abs;
103
+ }
104
+ const mjs = path.join(dir, "index.mjs");
105
+ if (fs.existsSync(mjs) && fs.statSync(mjs).isFile()) return mjs;
106
+ if (fs.existsSync(pkgJsonPath)) {
107
+ try {
108
+ const main = (JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")) as { main?: string }).main;
109
+ if (main) {
110
+ const abs = path.resolve(dir, main);
111
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return abs;
112
+ }
113
+ } catch {
114
+ /* malformed package.json — skip */
115
+ }
116
+ }
117
+ return undefined;
118
+ }
119
+
65
120
  /**
66
121
  * Resolve the extension entry point for an installed Pi package.
67
122
  * Order: package.json `pi.extensions` (array) → `index.ts` → `index.mjs` → `index.js` → `src/index.ts`.
@@ -80,7 +135,18 @@ function resolvePackageEntry(pkgDir: string): string | undefined {
80
135
  if (Array.isArray(piExts) && piExts.length > 0) {
81
136
  for (const rel of piExts) {
82
137
  const abs = path.resolve(pkgDir, rel);
83
- if (fs.existsSync(abs)) return abs;
138
+ if (!fs.existsSync(abs)) continue;
139
+ // GH #61: a DIRECTORY entry (e.g. pi-web-access "./dist") must be
140
+ // normalized to the file identity Pi's auto-load uses — injecting the
141
+ // raw dir double-loads the extension in child workers (tool conflicts,
142
+ // exit 1). An unresolvable dir is SKIPPED, matching Pi's own view of
143
+ // the package (its auto-load would not register it either).
144
+ if (fs.statSync(abs).isDirectory()) {
145
+ const entry = resolveExtensionDirEntry(abs);
146
+ if (entry) return entry;
147
+ continue;
148
+ }
149
+ return abs;
84
150
  }
85
151
  }
86
152
  // Fall back to `main` when it points at a loadable entry (rare for extensions).
@@ -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.