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
@@ -322,6 +322,29 @@ function modelFromRegistry(modelRegistry: unknown, modelId: string | undefined):
322
322
  }
323
323
  }
324
324
 
325
+ /**
326
+ * R3-12: resolve pi-crew's fallback chain into pi SDK `scopedModels` entries.
327
+ *
328
+ * SDK semantics (pi-coding-agent docs/sdk.md; dist/core/agent-session.js):
329
+ * `scopedModels` bounds the session's CYCLING scope and what extensions see
330
+ * through `getScopedModels` — it is NOT a hard access gate (`setModel` still
331
+ * accepts any credentialed model), so binding the fallback chain cannot break
332
+ * a legitimate user/model override. Candidates that don't resolve in the
333
+ * registry are skipped; an empty result means the caller passes NO option so
334
+ * pi keeps its default scope (all available models) — the "explicit
335
+ * non-empty" guard from the R3 spec.
336
+ */
337
+ export function resolveScopedSessionModels(modelRegistry: unknown, candidates: readonly string[]): Array<{ model: unknown }> {
338
+ const scoped: Array<{ model: unknown }> = [];
339
+ for (const candidate of candidates) {
340
+ const model = modelFromRegistry(modelRegistry, candidate);
341
+ if (!model || typeof model !== "object") continue;
342
+ if (scoped.some((entry) => entry.model === model)) continue;
343
+ scoped.push({ model });
344
+ }
345
+ return scoped;
346
+ }
347
+
325
348
  /**
326
349
  * Round 18: when agent declares `model: false`, the inherited `parentModel`
327
350
  * (= `ctx.model` from Pi runtime, set via `team-tool.ts:541/655`) is the
@@ -793,6 +816,12 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
793
816
  // H1.a: teamRole.thinking takes precedence over agent.thinking.
794
817
  const effectiveThinking = input.teamRoleThinking ?? input.agent.thinking;
795
818
 
819
+ // R3-12: bind the live session's model scope to pi-crew's fallback chain
820
+ // so model cycling (and the extension-visible scope) cannot drift outside
821
+ // the candidates the routing gate approved. Only bound when the chain
822
+ // resolves to at least one registry model (explicit non-empty guard).
823
+ const scopedSessionModels = resolveScopedSessionModels(input.modelRegistry, modelRouting.candidates);
824
+
796
825
  // G1: Build custom tools (submit_result + irc)
797
826
  const submitResultTool = createSubmitResultTool((result) => {
798
827
  customToolYieldResult = result;
@@ -819,6 +848,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
819
848
  ...(input.modelRegistry ? { modelRegistry: input.modelRegistry } : {}),
820
849
  ...(resolvedModel ? { model: resolvedModel } : {}),
821
850
  ...(effectiveThinking ? { thinkingLevel: effectiveThinking } : {}),
851
+ // R3-12: cycling/visibility scope = the fallback chain (see helper).
852
+ ...(scopedSessionModels.length > 0 ? { scopedModels: scopedSessionModels } : {}),
822
853
  customTools,
823
854
  });
824
855
  session = created.session;
@@ -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,10 +2,15 @@ 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";
8
+ import { PI_NO_MCP_FLOOR, piVersionAtLeast } from "../child-pi/pi-version.ts";
7
9
 
8
- const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
10
+ // U4 (pi 1.0.4 adoption): pi's CLI documents 7 thinking levels (docs/cli.md:70);
11
+ // `max` completes the surface — frontmatter `thinking: max` previously fell
12
+ // back to Pi's default via isValidThinkingLevel.
13
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
9
14
  // FIX (2026-07-02): use packageRoot() instead of import.meta.url-relative path.
10
15
  // The previous path.resolve(path.dirname(fileURLToPath(import.meta.url)),
11
16
  // "..", "prompt", "prompt-runtime.ts") was correct in source but BROKEN
@@ -40,6 +45,24 @@ export interface BuildPiWorkerArgsInput {
40
45
  agent: AgentConfig;
41
46
  model?: string;
42
47
  sessionEnabled?: boolean;
48
+ /** W2 (session-file recovery): deterministic pi session id for this worker
49
+ * (`--session-id`, SDK ≥ 0.76.0 — below pi-crew's tested host floor 0.99.2,
50
+ * so emitted unconditionally when set; no capability gate). Same id across
51
+ * retries appends to one session file (SDK §R2.2) — cross-attempt resume. */
52
+ sessionId?: string;
53
+ /** W2 (session-file recovery): directory for this worker's session JSONL
54
+ * (`--session-dir`, SDK ≥ 0.30.0). Run-scoped per-worker dir, e.g.
55
+ * `<artifactsRoot>/sessions/<taskId>/` — lives and dies with run artifacts. */
56
+ sessionDir?: string;
57
+ /** R3-15 (Pi 1.0.0 deep-learn r3): worker task id for the human-facing
58
+ * session display name — emits `--name crew-<taskId>` (cli.md:100, `-n/--name`
59
+ * "Sets the session display name"). Cosmetic only: W2's deterministic
60
+ * --session-id stays the machine-readable correlation key. Emitted inside
61
+ * the sessionEnabled block (a name labels a persisted session; under
62
+ * --no-session nothing persists). NOT stripped for surface TUI panes — a
63
+ * display name is not headless-only, it labels the pane's session too
64
+ * (child-pi.ts stripHeadlessOnlyFlags stays untouched). */
65
+ taskId?: string;
43
66
  maxDepth?: number;
44
67
  skillPaths?: string[];
45
68
  env?: NodeJS.ProcessEnv;
@@ -47,6 +70,34 @@ export interface BuildPiWorkerArgsInput {
47
70
  role?: string;
48
71
  /** Per-role thinking override (teamRole.thinking). Takes precedence over agent.thinking. */
49
72
  thinkingOverride?: string;
73
+ /** R3-1 (Pi 1.0.0 deep-learn round 3): run-static worker header (Protocol
74
+ * block, mailbox contract, workspace structure, runtime context) riding the
75
+ * `--append-system-prompt` channel instead of the user-message concatenation.
76
+ * Compaction summarizes the user-message span (compaction.md:150-160) but the
77
+ * system prompt survives intact — the parts that must NEVER be lost move here.
78
+ * Task text + dependency context stay in the user message (they SHOULD be
79
+ * summarizable). Final append order: builtin → agent.systemPrompt (if any,
80
+ * honoring its declared replace/append mode) → this header. Probe-verified
81
+ * 2026-10-04 (/tmp/pi-r3impl): the append channel and AGENTS.md discovery
82
+ * (project_context section) coexist in one system message. */
83
+ systemPromptAppend?: string;
84
+ /** R3-19/D5 (hermetic worker spawns): pass `--no-extensions` on the worker
85
+ * argv. Resolved via resolveHermeticWorkers — env
86
+ * PI_CREW_HERMETIC_WORKERS beats this beats default TRUE. Explicit `-e
87
+ * prompt-runtime` (pushed unconditionally below), agent `extensions:`
88
+ * declarations, and `--skill` flags are unaffected (cli.md:186-191:
89
+ * explicit -e and --skill survive --no-extensions/--no-skills). */
90
+ hermeticWorkers?: boolean;
91
+ /** U9 (pi 1.0.4 version floor): the HOST pi binary's version as probed by
92
+ * `pi --version` (probePiVersion — single-flight memoized per process,
93
+ * injectable exec seam, never rejects), or null when unknown (probe
94
+ * failed/timed out/unparseable). PURE INPUT — the builder NEVER spawns:
95
+ * the async spawn path (runChildPi) awaits the memoized probe upstream and
96
+ * threads the resolved string|null down through here. Drives the
97
+ * version-gated flags registered in child-pi/pi-version.ts (`--no-mcp` at
98
+ * the 1.0.4 floor, parity spawns only); unknown/below-floor conservatively
99
+ * emits NOTHING — a pre-floor host's strict parser rejects unknown flags. */
100
+ piVersion?: string | null;
50
101
  }
51
102
 
52
103
  export interface BuildPiWorkerArgsResult {
@@ -90,6 +141,17 @@ export function resolveCrewMaxDepth(inputMaxDepth?: number, env: NodeJS.ProcessE
90
141
  return DEFAULT_MAX_CREW_DEPTH;
91
142
  }
92
143
 
144
+ /**
145
+ * R3-19/D5 (hermetic worker spawns): resolution order mirrors
146
+ * resolveSessionRecoveryEnabled — env PI_CREW_HERMETIC_WORKERS beats the
147
+ * explicit config/runtime value beats default TRUE.
148
+ */
149
+ export function resolveHermeticWorkers(explicit?: boolean): boolean {
150
+ const env = getCrewEnvBool("PI_CREW_HERMETIC_WORKERS");
151
+ if (env !== undefined) return env;
152
+ return explicit ?? true;
153
+ }
154
+
93
155
  export function checkCrewDepth(
94
156
  inputMaxDepth?: number,
95
157
  env: NodeJS.ProcessEnv = process.env,
@@ -265,6 +327,22 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
265
327
  // never sets it, so it can never be matched as a sub-agent.
266
328
  const args = ["--mode", "json", "-p"];
267
329
  if (input.sessionEnabled === false) args.push("--no-session");
330
+ // W2 (session-file recovery): deterministic session identity so a worker
331
+ // that dies without a final assistant event (exitCode null / killed) can
332
+ // have its last COMPLETE assistant turn tail-recovered from the session
333
+ // JSONL (child-pi.ts settle path → session-recovery.ts). Both flags are
334
+ // SDK-era flags older than the tested host floor (fact pack §1: session-id
335
+ // 0.76.0, session-dir 0.30.0 < 0.99.2) → unconditional add, same as the
336
+ // existing --no-session/--model emissions. Skipped when sessions are off —
337
+ // under --no-session pi persists nothing, the flags would be meaningless.
338
+ if (input.sessionEnabled !== false) {
339
+ if (input.sessionId) args.push("--session-id", input.sessionId);
340
+ if (input.sessionDir) args.push("--session-dir", input.sessionDir);
341
+ // R3-15: human-facing display name — `crew-<taskId>` makes crew workers
342
+ // distinguishable in pi's session picker (flag predates nothing: -n/--name
343
+ // is a long-standing CLI flag, cli.md:100 — no capability gate needed).
344
+ if (input.taskId) args.push("--name", `crew-${input.taskId}`);
345
+ }
268
346
 
269
347
  const resolvedModel = input.model ?? input.agent.model;
270
348
  // H1.a: teamRole.thinking (passed as thinkingOverride) takes precedence over agent.thinking.
@@ -278,8 +356,13 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
278
356
  args.push("--thinking", effectiveThinking);
279
357
  }
280
358
 
281
- // D5 (spec v0.7 §6): default loadout = FULL session (như main session).
282
- // --no-extensions/--no-skills/--tools CHỈ xuất hiện khi agent .md khai explicit.
359
+ // D5 (spec v0.7 §6): default loadout = FULL session (như main session) —
360
+ // --no-skills/--tools CHỈ xuất hiện khi agent .md khai explicit. R3-19/D5
361
+ // reversal (2026-10-04): extension discovery is now hermetic by default
362
+ // (--no-extensions below, off-switch runtime.hermeticWorkers / env
363
+ // PI_CREW_HERMETIC_WORKERS) — the ambient package stack cost ~1.37s/spawn
364
+ // and leaked 8 direct-exposure MCP tools + 5 host tools into every worker
365
+ // (probe C §R3b.6: 431ms vs 1806ms to first stdout record).
283
366
  // "message" MUST stay in CONTROL_TOOLS: it is registered by prompt-runtime
284
367
  // behind the PI_CREW_MSG_ENABLED dormant-env gate (child-pi-spawn.ts sets it
285
368
  // unconditionally), but pi's --tools allowlist filters the tool SURFACE — a
@@ -311,8 +394,40 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
311
394
  // B2 (fix round 1): `disallowedTools:` frontmatter is a
312
395
  // declaration-driven denylist (opt-in like `tools:`) — NOT the
313
396
  // role-based policy D5 removed.
314
- const disallowed = (input.agent.disallowedTools ?? []).map((t) => t.trim()).filter(Boolean);
315
- if (disallowed.length > 0) args.push("--exclude-tools", [...new Set(disallowed)].join(","));
397
+ // U2-lite (pi 1.0.4 `--tools` semantics drift): on 1.0.4, `--tools` no
398
+ // longer cuts MCP — ambient MCP servers stay connected on parity
399
+ // (NON-hermetic) spawns. Fold `mcp__*` into the exclude list to restore
400
+ // the pre-1.0.4 MCP cut. Kept OUT of hermetic spawns: --no-extensions
401
+ // already disables builtin:mcp (hermetic spawns are MCP-clean — the
402
+ // conflict verdict in docs/reviews/pi-1.0.4-upgrade-notes-2026-10-06.md),
403
+ // and the default loadout pins !--exclude-tools
404
+ // (test/unit/runtime/model/pi-args-loadout.test.ts). Cross-version-safe:
405
+ // on pre-1.0.4 hosts the `mcp__*` pattern matches no literal tool name
406
+ // → benign no-op. ONE flag value only — pi's strict parser rejects a
407
+ // duplicate --exclude-tools flag, so declared disallowedTools merge
408
+ // comma-sep ahead of the pattern. Stays inside the else of disableTools:
409
+ // `--no-tools` already locks the whole tool surface, an extra cut would
410
+ // be dead weight. Surface TUI panes strip argv flags after build
411
+ // (child-pi.ts stripHeadlessOnlyFlags), so this cut never reaches them.
412
+ const disallowed = new Set((input.agent.disallowedTools ?? []).map((t) => t.trim()).filter(Boolean));
413
+ const paritySpawn = !resolveHermeticWorkers(input.hermeticWorkers);
414
+ if (paritySpawn) disallowed.add("mcp__" + "*");
415
+ if (disallowed.size > 0) args.push("--exclude-tools", [...disallowed].join(","));
416
+ // U9 (version floor, registry PI_NO_MCP_FLOOR in child-pi/pi-version.ts;
417
+ // flag doc cli.md:198): parity spawn on a host pi >= 1.0.4 ALSO passes
418
+ // `--no-mcp` — "no servers connect, and there are no MCP tools or /mcp",
419
+ // strictly stronger than the mcp__* tool-cut above (servers never even
420
+ // handshake). Below the floor / unknown (null) / malformed piVersion →
421
+ // NO flag: pi's strict option parser rejects unknown flags with a
422
+ // nonzero exit, so guessing wrong would break every parity spawn. The
423
+ // mcp__* fold STAYS alongside — it is the cross-version-safe baseline
424
+ // (pre-1.0.4 hosts have no --no-mcp to lean on). Hermetic spawns never
425
+ // reach here (--no-extensions already makes them MCP-clean). Kept inside
426
+ // the else of disableTools like the fold (--no-tools already locks the
427
+ // whole surface). Surface TUI panes KEEP the flag — stripHeadlessOnlyFlags
428
+ // only strips --no-approve/--no-extensions, same treatment as
429
+ // --exclude-tools: parity semantics must not depend on spawn mode.
430
+ if (paritySpawn && piVersionAtLeast(input.piVersion, PI_NO_MCP_FLOOR)) args.push("--no-mcp");
316
431
  }
317
432
  // prompt-runtime extension luôn nạp (hạ tầng phối hợp — không phải cắt xén).
318
433
  args.push("--extension", PROMPT_RUNTIME_EXTENSION_PATH);
@@ -332,7 +447,13 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
332
447
  const excluded = new Set((input.agent.excludeExtensions ?? []).map((name) => path.basename(name).toLowerCase()));
333
448
  declaredExtensions = declaredExtensions.filter((ext) => !excluded.has(path.basename(ext).toLowerCase()));
334
449
  for (const ext of declaredExtensions) args.push("--extension", ext);
335
- // KHÔNG còn --no-extensions (extension discovery hoạt động như main session).
450
+ // R3-19/D5: hermetic spawns — disable discovered/configured/built-in
451
+ // extensions on worker argv. cli.md:186-187: "Explicit -e paths still
452
+ // load" — the unconditional prompt-runtime push above and the agent
453
+ // `extensions:` declarations below survive; --skill flags are a separate
454
+ // gate. Default ON (resolveHermeticWorkers); surface TUI panes strip the
455
+ // flag (child-pi.ts stripHeadlessOnlyFlags).
456
+ if (resolveHermeticWorkers(input.hermeticWorkers)) args.push("--no-extensions");
336
457
  if (input.agent.inheritSkills === false) args.push("--no-skills");
337
458
  for (const skillPath of input.skillPaths ?? []) args.push("--skill", skillPath);
338
459
 
@@ -346,6 +467,45 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
346
467
  atomicWriteFile(promptPath, input.agent.systemPrompt, { mode: 0o600 });
347
468
  args.push(input.agent.systemPromptMode === "append" ? "--append-system-prompt" : "--system-prompt", promptPath);
348
469
  }
470
+ // R3-1: run-static header rides its own --append-system-prompt file. pi
471
+ // parses --system-prompt and --append-system-prompt independently and the
472
+ // append flag is repeatable (cli/args.js:71-77; appends join with "\n\n" in
473
+ // argv order), so pushing it AFTER the agent flag yields the required final
474
+ // order builtin → agent.systemPrompt → run-static header in BOTH modes
475
+ // (replace mode: agent file replaces builtin, header appends after it;
476
+ // append mode: two append files in argv order).
477
+ if (input.systemPromptAppend?.trim()) {
478
+ if (!tempDir) {
479
+ const tmpBase = getPiTempBase();
480
+ tempDir = createSafeTempDir(tmpBase, `pi-crew-${process.pid}-`);
481
+ }
482
+ const headerPath = path.join(tempDir, "worker-header.md");
483
+ atomicWriteFile(headerPath, input.systemPromptAppend, { mode: 0o600 });
484
+ args.push("--append-system-prompt", headerPath);
485
+ }
486
+
487
+ // D1 (R3-3, pinned 2026-10-04): deterministic project-trust for worker
488
+ // spawns — `--no-approve` (cli alias -na). The command-line override applies
489
+ // FIRST in pi's trust resolution (security.md "How Pi chooses a trust
490
+ // decision"), before any extension, saved decision in ~/.pi/agent/trust.json,
491
+ // or defaultProjectTrust — so worker behavior no longer varies with the
492
+ // ambient trust store of the machine (P-C probe: /home/bom/source/my_pi and
493
+ // /tmp were pre-trusted there). Decision rationale vs the D1 criteria:
494
+ // (a) no ambient-trust dependency ✓ (CLI override wins);
495
+ // (b) write capability intact ✓ (security.md:33 — project trust does NOT
496
+ // limit what tool calls can access; only startup resource loading is
497
+ // gated, so workers still write files in the task cwd);
498
+ // (c) minimal scope ✓ (declines rather than grants; one command; not
499
+ // persisted to trust.json).
500
+ // AGENTS.md/CLAUDE.md context files load REGARDLESS of project trust
501
+ // (security.md:57) — probe-verified 2026-10-04 (/tmp/pi-r3impl probe2: the
502
+ // project_context section is present with --no-approve), so the R3-18
503
+ // repo-instruction auto-load is preserved. `-a` was rejected: it grants
504
+ // maximal project trust (loads .pi/mcp.json, .pi/extensions… from untrusted
505
+ // task cwds) — the opposite of minimal scope, and redundant with the D5
506
+ // hermetic posture. Surface TUI spawns strip this flag (child-pi.ts
507
+ // trySurfaceBranch) to keep the interactive trust prompt available.
508
+ args.push("--no-approve");
349
509
 
350
510
  // G3 (SDD-2 W-B, spill-always): task text NEVER rides argv — argv is
351
511
  // world-readable via /proc/<pid>/cmdline, leaking task content (and any
@@ -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
  }