pi-crew 0.7.6 → 0.8.1

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.
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { listRecentRuns } from "../run-index.ts";
3
+ import { extractSessionId } from "../../utils/session-utils.ts";
3
4
  import type { ArtifactDescriptor, TeamRunManifest } from "../../state/types.ts";
4
5
 
5
6
  export interface RegisterCompactionGuardOptions {
@@ -71,11 +72,25 @@ function formatCrewArtifactIndex(entries: CrewArtifactIndexEntry[]): string {
71
72
  /**
72
73
  * Collect in-flight (non-terminal) crew runs that must be resumable after
73
74
  * compaction. These are runs the agent was actively working on or awaiting.
75
+ *
76
+ * @param cwd - project working directory (shared, per-project state root).
77
+ * @param currentSessionId - if provided, restrict to runs OWNED BY THIS
78
+ * session (`run.ownerSessionId === currentSessionId`). The state store is
79
+ * per-PROJECT, not per-SESSION — multiple sessions share `.crew/state/runs/`.
80
+ * Without this filter, Session B's compaction would pick up Session A's
81
+ * in-flight runs and wrongly resume them. Legacy runs with no
82
+ * `ownerSessionId` are excluded under filtering (strict): a run with no
83
+ * declared owner must not be auto-resumed by an arbitrary session; true
84
+ * orphans are handled separately by crash-recovery. When omitted, returns
85
+ * ALL in-flight runs (back-compat for callers that deliberately want the
86
+ * cross-session view, e.g. diagnostics).
74
87
  */
75
- export function collectInFlightRuns(cwd: string): TeamRunManifest[] {
76
- return listRecentRuns(cwd, MAX_ARTIFACT_INDEX_RUNS).filter((run) =>
77
- IN_FLIGHT_RUN_STATUSES.has(run.status),
78
- );
88
+ export function collectInFlightRuns(cwd: string, currentSessionId?: string): TeamRunManifest[] {
89
+ return listRecentRuns(cwd, MAX_ARTIFACT_INDEX_RUNS).filter((run) => {
90
+ if (!IN_FLIGHT_RUN_STATUSES.has(run.status)) return false;
91
+ if (currentSessionId === undefined) return true; // no filter → back-compat
92
+ return run.ownerSessionId === currentSessionId; // strict: legacy ownerless runs excluded
93
+ });
79
94
  }
80
95
 
81
96
  /**
@@ -130,29 +145,43 @@ export function buildContinuationPrompt(runs: TeamRunManifest[]): string {
130
145
  * Trigger automatic agent continuation after compaction. Fire-and-forget the
131
146
  * promise — never block the compaction flow. The sendUserMessage type is
132
147
  * declared `void` but the runtime returns a Promise (it triggers an agent turn).
148
+ *
149
+ * During compaction the agent may still be mid-processing, so Pi can reject
150
+ * the queued message with "Agent is already processing a prompt...". This is
151
+ * BENIGN — the in-flight worker continues independently regardless — so we
152
+ * detect that specific race and downgrade it to a silent debug log instead of
153
+ * surfacing a scary warning to the user. Other errors still notify.
133
154
  */
134
155
  export function triggerContinuation(pi: ExtensionAPI, ctx: ExtensionContext, runs: TeamRunManifest[]): void {
135
156
  if (!runs.length) return;
136
157
  const prompt = buildContinuationPrompt(runs);
158
+ const isBenignProcessingRace = (err: unknown): boolean => {
159
+ const msg = err instanceof Error ? err.message : String(err ?? "");
160
+ return /already processing a prompt/i.test(msg) || /use steer\(\) or followUp\(\)/i.test(msg);
161
+ };
137
162
  try {
138
163
  const result = pi.sendUserMessage(prompt) as unknown;
139
- Promise.resolve(result).catch(() => {
140
- // best-effort: if continuation fails, at least notify
164
+ Promise.resolve(result).catch((err: unknown) => {
165
+ // Benign race: the worker keeps running independently — no need to alarm.
166
+ if (isBenignProcessingRace(err)) return;
167
+ // Real failure: surface a hint so the user can resume manually.
141
168
  try {
142
169
  ctx.ui.notify("pi-crew: auto-continuation after compaction failed — use team status to resume manually.", "warning");
143
170
  } catch {
144
171
  // swallow
145
172
  }
146
173
  });
147
- } catch {
174
+ } catch (err: unknown) {
175
+ // Synchronous throw — same benign-race handling.
176
+ if (isBenignProcessingRace(err)) return;
148
177
  // best-effort
149
178
  }
150
179
  }
151
180
 
152
181
  /** Combined customInstructions injected into proactive compaction summaries. */
153
- function buildCompactionInstructions(cwd: string): string {
182
+ function buildCompactionInstructions(cwd: string, currentSessionId?: string): string {
154
183
  const artifactIndex = collectCrewArtifactIndex(cwd);
155
- const inFlight = collectInFlightRuns(cwd);
184
+ const inFlight = collectInFlightRuns(cwd, currentSessionId);
156
185
  const parts = [
157
186
  "Prioritize keeping pi-crew run state, task results, artifact references, run IDs, and next actions. Keep completed-task detail concise.",
158
187
  ];
@@ -168,10 +197,11 @@ export function registerCompactionGuard(pi: ExtensionAPI, options: RegisterCompa
168
197
  const startCompact = (ctx: ExtensionContext, reason: string): void => {
169
198
  if (compactionInProgress) return;
170
199
  compactionInProgress = true;
171
- const customInstructions = buildCompactionInstructions(ctx.cwd);
200
+ const sessionId = extractSessionId(ctx);
201
+ const customInstructions = buildCompactionInstructions(ctx.cwd, sessionId);
172
202
  // Append a durable resume entry so it appears in the post-compaction
173
203
  // context regardless of how summarization treats customInstructions.
174
- const inFlight = collectInFlightRuns(ctx.cwd);
204
+ const inFlight = collectInFlightRuns(ctx.cwd, sessionId);
175
205
  if (inFlight.length > 0) {
176
206
  pi.appendEntry("crew:resume-directive", {
177
207
  reason,
@@ -192,7 +222,7 @@ export function registerCompactionGuard(pi: ExtensionAPI, options: RegisterCompa
192
222
  // O10 FIX: Pi's threshold compaction does NOT auto-retry — it
193
223
  // stops and waits for user input. Trigger automatic
194
224
  // continuation so the agent resumes the in-flight crew task.
195
- const runs = collectInFlightRuns(ctx.cwd);
225
+ const runs = collectInFlightRuns(ctx.cwd, extractSessionId(ctx));
196
226
  triggerContinuation(pi, ctx, runs);
197
227
  ctx.ui.notify(reason === "deferred" ? "Deferred compaction completed" : "Auto-compacted context during team run", "info");
198
228
  },
@@ -219,7 +249,8 @@ export function registerCompactionGuard(pi: ExtensionAPI, options: RegisterCompa
219
249
  // our proactive startCompact path.
220
250
  pi.on("session_compact", (_event, ctx) => {
221
251
  try {
222
- const inFlight = collectInFlightRuns(ctx.cwd);
252
+ const sessionId = extractSessionId(ctx);
253
+ const inFlight = collectInFlightRuns(ctx.cwd, sessionId);
223
254
  if (inFlight.length === 0) return;
224
255
  // Re-append the resume directive entry for durable record.
225
256
  pi.appendEntry("crew:resume-directive", {
@@ -205,6 +205,8 @@ const KNOWN_KEYS = new Set([
205
205
  "reliability.retryPolicy.jitterRatio",
206
206
  "reliability.retryPolicy.exponentialFactor",
207
207
  "reliability.retryPolicy.retryableErrors",
208
+ // F7: opt-in model scope enforcement (hard-error caller out-of-scope, warn frontmatter).
209
+ "reliability.scopeModels",
208
210
  // otlp
209
211
  "otlp.enabled",
210
212
  "otlp.endpoint",
@@ -9,7 +9,7 @@ import type { TeamTaskState } from "../state/types.ts";
9
9
  import { isWorkerHeartbeatStale } from "./worker-heartbeat.ts";
10
10
  import type { ManifestCache } from "./manifest-cache.ts";
11
11
  import { checkProcessLiveness } from "./process-status.ts";
12
- import { reconcileStaleRun, type ReconcileResult } from "./stale-reconciler.ts";
12
+ import { isPlanApprovalPending, reconcileStaleRun, type ReconcileResult } from "./stale-reconciler.ts";
13
13
  import { executeHook, appendHookEvent } from "../hooks/registry.ts";
14
14
  import { unregisterActiveRun, readActiveRunRegistry } from "../state/active-run-registry.ts";
15
15
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
@@ -38,6 +38,8 @@ export function detectInterruptedRuns(cwd: string, manifestCache: ManifestCache,
38
38
  const plans: RecoveryPlan[] = [];
39
39
  for (const manifest of manifestCache.list(50)) {
40
40
  if (manifest.status !== "running" && manifest.status !== "blocked") continue;
41
+ // Preserve runs intentionally blocked on plan approval — not crashes.
42
+ if (isPlanApprovalPending(manifest)) continue;
41
43
  if (manifest.async?.pid !== undefined && checkProcessLiveness(manifest.async.pid).alive) continue;
42
44
  // NOTE: no withRunLock — best-effort only; concurrent writes may cause inconsistency
43
45
  const loaded = loadRunManifestById(cwd, manifest.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
@@ -107,6 +109,12 @@ export function cancelOrphanedRuns(
107
109
  // Phase 1: Scan project-level manifests via manifestCache
108
110
  for (const manifest of manifestCache.list(50)) {
109
111
  if (manifest.status !== "running" && manifest.status !== "blocked") continue;
112
+ // Preserve plan-approval-blocked runs — they belong to their owner and are
113
+ // waiting on a human decision, not orphaned by a dead owner process.
114
+ if (isPlanApprovalPending(manifest)) {
115
+ skipped.push(manifest.runId);
116
+ continue;
117
+ }
110
118
 
111
119
  // Only consider runs owned by a different session
112
120
  const ownerId = manifest.ownerSessionId;
@@ -340,6 +348,18 @@ export function reconcileAllStaleRuns(cwd: string, manifestCache: ManifestCache,
340
348
  // Re-read inside lock to get freshest data
341
349
  const fresh = loadRunManifestById(cwd, runId); // NOTE: inside withRunLockSync - consistent read
342
350
  if (!fresh || (fresh.manifest.status !== "running" && fresh.manifest.status !== "blocked")) return;
351
+ // Belt-and-suspenders: reconcileStaleRun itself guards this, but the run
352
+ // may have flipped to blocked+plan-approval between cache-list and lock
353
+ // acquisition — re-check the freshest manifest under the lock.
354
+ if (isPlanApprovalPending(fresh.manifest)) {
355
+ results.push({
356
+ runId,
357
+ verdict: "blocked_awaiting_approval",
358
+ repaired: false,
359
+ detail: "Plan approval is pending; stale reconciliation skipped",
360
+ });
361
+ return;
362
+ }
343
363
  const result = reconcileStaleRun(fresh.manifest, fresh.tasks, now);
344
364
  if (result.repaired || result.verdict === "result_exists") {
345
365
  if (result.repairedTasks) {
@@ -15,6 +15,9 @@ import type { WorkflowStep } from "../workflows/workflow-config.ts";
15
15
  import { isLiveSessionRuntimeAvailable } from "./runtime-resolver.ts";
16
16
  import { redactSecrets } from "../utils/redaction.ts";
17
17
  import { buildConfiguredModelRouting } from "./model-fallback.ts";
18
+ import { readEnabledModelsPatterns } from "./model-scope.ts";
19
+ import { resolveToolPolicy } from "../agents/agent-config.ts";
20
+ import { loadConfig } from "../config/config.ts";
18
21
  import { DEFAULT_LIVE_SESSION } from "../config/defaults.ts";
19
22
  import { buildYieldReminder, hasYieldInOutput, isYieldEvent, extractYieldResult, validateYieldData, DEFAULT_YIELD_CONFIG, type YieldResult } from "./yield-handler.ts";
20
23
  import { buildMcpProxyFromSession } from "./mcp-proxy.ts";
@@ -28,6 +31,30 @@ import { buildSensitivePathConstraint } from "./sensitive-paths.ts";
28
31
  import { collectLiveSessionHealth, formatLiveSessionDiagnostics, type LiveSessionHealth } from "./live-session-health.ts";
29
32
  import { listLiveAgents } from "./live-agent-manager.ts";
30
33
 
34
+ /**
35
+ * Module-scoped latch for the optional peer dependency import. When N
36
+ * in-process live-session subagents spawn CONCURRENTLY (e.g. several
37
+ * `Agent({run_in_background:true})` started at once), each used to call
38
+ * `await import("@earendil-works/pi-coding-agent")` independently. Under the
39
+ * tsx loader (registering load/resolve hooks), concurrent first-imports can
40
+ * each enter the loader and race module-record instantiation, yielding
41
+ * `Cannot read properties of undefined (reading 'existsSync')` /
42
+ * `'validateWorkflowForTeam'` as namespace bindings observed mid-evaluation.
43
+ * Sequential retries always succeed → this is a cold-start race, not a logic
44
+ * bug. ESM engines memoize imports, but that memoization is not guaranteed
45
+ * to be observed synchronously across concurrent evaluation under transpiling
46
+ * loaders, so we add an explicit JS-level latch: the first caller wins, every
47
+ * later caller awaits the same in-flight promise. (Observed 2026-06-16 when 4
48
+ * explorer subagents launched together; 3 of 4 crashed.)
49
+ */
50
+ let liveSessionModulePromise: Promise<LiveSessionModule> | undefined;
51
+ function loadLiveSessionModule(): Promise<LiveSessionModule> {
52
+ if (!liveSessionModulePromise) {
53
+ liveSessionModulePromise = import("@earendil-works/pi-coding-agent") as unknown as Promise<LiveSessionModule>;
54
+ }
55
+ return liveSessionModulePromise;
56
+ }
57
+
31
58
  export interface LiveSessionSpawnInput {
32
59
  manifest: TeamRunManifest;
33
60
  task: TeamTaskState;
@@ -179,6 +206,25 @@ function numberField(obj: Record<string, unknown> | undefined, keys: string[]):
179
206
  return undefined;
180
207
  }
181
208
 
209
+ /**
210
+ * F7: resolve the enabledModels allowlist for the current project, but only
211
+ * if the `runtime.reliability.scopeModels` toggle is ON. Returns an empty
212
+ * array when the toggle is off or no allowlist is configured — the routing
213
+ * gate treats empty patterns as "no enforcement" (no-op). Best-effort:
214
+ * any failure to read the toggle or the allowlist silently disables the gate
215
+ * rather than blocking spawn.
216
+ */
217
+ async function resolveScopeModelsPatterns(cwd: string, agentDir?: string): Promise<string[]> {
218
+ let scopeModels = false;
219
+ try {
220
+ scopeModels = loadConfig(cwd).config.reliability?.scopeModels === true;
221
+ } catch {
222
+ return [];
223
+ }
224
+ if (!scopeModels) return [];
225
+ return readEnabledModelsPatterns(cwd, agentDir);
226
+ }
227
+
182
228
  function modelFromRegistry(modelRegistry: unknown, modelId: string | undefined): unknown {
183
229
  if (!modelId || !modelId.includes("/")) return undefined;
184
230
  const registry = asRecord(modelRegistry);
@@ -298,11 +344,17 @@ function liveSystemPrompt(input: LiveSessionSpawnInput): string {
298
344
  ].filter(Boolean).join("\n");
299
345
  }
300
346
 
301
- function filterActiveTools(session: LiveSessionLike, agent: AgentConfig): void {
347
+ function filterActiveTools(session: LiveSessionLike, agent: AgentConfig, role?: string): void {
302
348
  if (typeof session.getActiveToolNames !== "function" || typeof session.setActiveToolsByName !== "function") return;
303
349
  const recursiveTools = new Set(["team", "Team", "Agent", "get_subagent_result", "steer_subagent"]);
304
- const disallowed = agent.disallowedTools?.length ? new Set(agent.disallowedTools) : undefined;
305
- const allowed = agent.tools?.length ? new Set(agent.tools) : undefined;
350
+ // F1 unify (v0.8.0): use the shared resolveToolPolicy so this path agrees
351
+ // with child-pi (pi-args.ts). Before this, live-session used frontmatter
352
+ // only and ignored role-config entirely — so a builtin explorer on the
353
+ // live-session path wasn't bound by the role's read-only security constraint.
354
+ // Now allowlist precedence is source-aware and the denylist is additive.
355
+ const policy = resolveToolPolicy(agent, role);
356
+ const disallowed = policy.excludeTools?.length ? new Set(policy.excludeTools) : undefined;
357
+ const allowed = policy.tools?.length ? new Set(policy.tools) : undefined;
306
358
  const active = session.getActiveToolNames().filter((name) => !recursiveTools.has(name) && (!disallowed || !disallowed.has(name)) && (!allowed || allowed.has(name)));
307
359
  session.setActiveToolsByName(active);
308
360
  }
@@ -369,8 +421,11 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
369
421
  }
370
422
  const availability = await isLiveSessionRuntimeAvailable();
371
423
  if (!availability.available) return { available: true, exitCode: 1, stdout: "", stderr: availability.reason ?? "Live-session runtime unavailable.", jsonEvents: 0, error: availability.reason };
372
- // LAZY: optional peer dependency — only loaded when live-session runtime is chosen.
373
- const mod = await import("@earendil-works/pi-coding-agent") as unknown as LiveSessionModule;
424
+ // LAZY: optional peer dependency — only loaded when live-session runtime is
425
+ // chosen. Goes through the module-scoped latch (loadLiveSessionModule) so
426
+ // concurrent first-imports share ONE in-flight promise instead of racing
427
+ // module-record instantiation under the tsx loader.
428
+ const mod = await loadLiveSessionModule();
374
429
  if (typeof mod.createAgentSession !== "function") return { available: true, exitCode: 1, stdout: "", stderr: "createAgentSession export is unavailable.", jsonEvents: 0, error: "createAgentSession export is unavailable." };
375
430
  let session: LiveSessionLike | undefined;
376
431
  let unsubscribe: (() => void) | undefined;
@@ -393,6 +448,13 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
393
448
  try {
394
449
  const agentDir = typeof mod.getAgentDir === "function" ? mod.getAgentDir() : undefined;
395
450
  let resourceLoader: unknown;
451
+ // F1 (v0.7.9) NOTE: `agent.excludeExtensions` is applied on the
452
+ // child-pi path (see `pi-args.ts`). The live-session path loads
453
+ // extensions via pi's `DefaultResourceLoader`, which has no explicit
454
+ // per-extension allow/deny API at the point we hand off. For
455
+ // v0.7.9, the denylist is honored on the default async path only;
456
+ // the live-session path (opt-in via `runtime.preferLiveSession`)
457
+ // ignores it. This is a documented limitation, not a silent bug.
396
458
  if (mod.DefaultResourceLoader && agentDir) {
397
459
  resourceLoader = new mod.DefaultResourceLoader({
398
460
  cwd: input.task.cwd,
@@ -405,7 +467,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
405
467
  });
406
468
  await (resourceLoader as { reload?: () => Promise<void> }).reload?.();
407
469
  }
408
- const modelRouting = buildConfiguredModelRouting({ overrideModel: input.modelOverride, stepModel: input.step.model, teamRoleModel: input.teamRoleModel, agentModel: input.agent.model, fallbackModels: input.agent.fallbackModels, parentModel: input.parentModel, modelRegistry: input.modelRegistry, cwd: input.manifest.cwd });
470
+ const modelRouting = buildConfiguredModelRouting({ overrideModel: input.modelOverride, stepModel: input.step.model, teamRoleModel: input.teamRoleModel, agentModel: input.agent.model, fallbackModels: input.agent.fallbackModels, parentModel: input.parentModel, modelRegistry: input.modelRegistry, cwd: input.manifest.cwd, scopeModelsPatterns: await resolveScopeModelsPatterns(input.manifest.cwd) });
409
471
  const resolvedModel = modelFromRegistry(input.modelRegistry, modelRouting.candidates[0] ?? modelRouting.requested) ?? input.parentModel;
410
472
  // Phase 4: MCP proxy — will be determined after session creation
411
473
  // (we check parent's MCP tools and share connections when available)
@@ -434,7 +496,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
434
496
  });
435
497
  session = created.session;
436
498
  appendEvent(input.manifest.eventsPath, { type: "live-session.session_created", runId: input.manifest.runId, taskId: input.task.id, data: { elapsedMs: Date.now() - sessionCreateStart, modelFallbackMessage: created.modelFallbackMessage } });
437
- filterActiveTools(session, input.agent);
499
+ filterActiveTools(session, input.agent, input.task.role);
438
500
 
439
501
  // Diagnostic: log before bindExtensions so we can identify extension-loading hangs
440
502
  const bindExtensionsStart = Date.now();
@@ -1,6 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { errors } from "../errors.ts";
5
+ import { checkModelScope } from "./model-scope.ts";
4
6
  import { fuzzyResolveModelId } from "./model-resolver.ts";
5
7
 
6
8
  export interface AvailableModelInfo {
@@ -241,6 +243,15 @@ export interface ConfiguredModelRouting {
241
243
  requested?: string;
242
244
  candidates: string[];
243
245
  reason?: string;
246
+ /**
247
+ * F7 scope gate verdict. Populated when the caller passed `scopeModelsPatterns`.
248
+ * - `inScope: true` → the resolved model is inside the allowlist (or no allowlist).
249
+ * - `inScope: false, source: "caller"` → caller override is out-of-scope; the
250
+ * function throws `errors.modelOutOfScope` (hard error before spawn) UNLESS
251
+ * the caller marked it as a frontmatter override (`isFrontmatterOverride: true`),
252
+ * in which case the verdict is returned for the caller to log as a warning.
253
+ */
254
+ scopeVerdict?: import("./model-scope.ts").ModelScopeCheck;
244
255
  }
245
256
 
246
257
  export function buildConfiguredModelRouting(input: {
@@ -252,6 +263,19 @@ export function buildConfiguredModelRouting(input: {
252
263
  parentModel?: unknown;
253
264
  modelRegistry?: unknown;
254
265
  cwd?: string;
266
+ /**
267
+ * F7: when set, enforce the enabledModels allowlist. Caller-supplied out-of-
268
+ * scope models throw `errors.modelOutOfScope`; frontmatter-pinned out-of-scope
269
+ * models are returned as a `scopeVerdict` for the caller to log.
270
+ */
271
+ scopeModelsPatterns?: string[];
272
+ /**
273
+ * F7: when true, the `overrideModel` (if any) is treated as a frontmatter
274
+ * (agent) override rather than a per-spawn caller override — out-of-scope
275
+ * is a warning, not a hard error. Used when the agent config is the
276
+ * authoritative source.
277
+ */
278
+ isFrontmatterOverride?: boolean;
255
279
  }): ConfiguredModelRouting {
256
280
  const registryModels = availableModelInfosFromRegistry(input.modelRegistry);
257
281
  const configModels = configuredModelInfosFromPiConfig(input.cwd);
@@ -275,7 +299,21 @@ export function buildConfiguredModelRouting(input: {
275
299
  : candidates.length > 1
276
300
  ? "configured Pi fallback chain"
277
301
  : undefined;
278
- return { requested, candidates, reason };
302
+ // F7 scope gate: when `scopeModelsPatterns` is configured, check the
303
+ // resolved model. Caller-supplied (override/step/team role) out-of-scope
304
+ // is a HARD ERROR (we surface it via the verdict AND throw, so spawn aborts
305
+ // before any cost is incurred). Frontmatter-pinned out-of-scope is a
306
+ // WARNING returned on the verdict for the caller to log.
307
+ let scopeVerdict: ConfiguredModelRouting["scopeVerdict"];
308
+ if (input.scopeModelsPatterns && input.scopeModelsPatterns.length > 0) {
309
+ const resolved = candidates[0] ?? requested;
310
+ const source = input.overrideModel ? "caller" : input.agentModel ? "frontmatter" : "resolved";
311
+ scopeVerdict = checkModelScope(resolved, input.scopeModelsPatterns, source);
312
+ if (!scopeVerdict.inScope && source === "caller" && !input.isFrontmatterOverride) {
313
+ throw errors.modelOutOfScope(resolved ?? "", input.scopeModelsPatterns);
314
+ }
315
+ }
316
+ return { requested, candidates, reason, scopeVerdict };
279
317
  }
280
318
 
281
319
  export function buildConfiguredModelCandidates(input: Parameters<typeof buildConfiguredModelRouting>[0]): string[] {
@@ -0,0 +1,141 @@
1
+ /**
2
+ * model-scope.ts — Opt-in model-scope enforcement (F7).
3
+ *
4
+ * When `runtime.reliability.scopeModels` is enabled, subagent model choices
5
+ * that fall outside the user's pi `enabledModels` allowlist are flagged:
6
+ * - Caller-supplied (per-spawn override / step / team role) out-of-scope
7
+ * → HARD ERROR to orchestrator (fail fast before spawn).
8
+ * - Frontmatter-pinned (AgentConfig.model) out-of-scope
9
+ * → WARNING + runs anyway (frontmatter is authoritative; the agent
10
+ * author made a deliberate choice).
11
+ *
12
+ * Pattern semantics match pi's `--models` CLI / `enabledModels` allowlist:
13
+ * - `"anthropic/claude-opus-4-5"` — exact match (case-insensitive).
14
+ * - `"claude-*"`, `"*sonnet*"`, `"github-copilot/*"` — glob (single `*`).
15
+ * - Any other string — case-insensitive substring fallback (pi's
16
+ * `tryMatchModel` behavior, model-resolver.ts).
17
+ *
18
+ * This module is pure (no I/O, no globals). Reading the actual
19
+ * `enabledModels` from pi's settings is the caller's job (instantiate
20
+ * `SettingsManager.create(cwd, agentDir).getEnabledModels()`).
21
+ *
22
+ * The toggle itself lives in `config/defaults.ts` (`reliability.scopeModels`,
23
+ * default `false` = opt-in, fully back-compat).
24
+ */
25
+
26
+ export type ModelScopeSource = "caller" | "frontmatter" | "resolved" | "fallback";
27
+
28
+ export interface ModelScopeCheck {
29
+ /** True when the model is in scope, or no allowlist is configured. */
30
+ inScope: boolean;
31
+ /** What the model came from. Informational; the gate decision lives in `enforce`. */
32
+ source: ModelScopeSource;
33
+ /** The model id that was checked. */
34
+ model: string;
35
+ /** The pattern(s) that matched, or undefined when no allowlist was configured. */
36
+ matchedPattern?: string;
37
+ /** Human-readable reason for out-of-scope (caller-facing when rejected). */
38
+ reason?: string;
39
+ }
40
+
41
+ /**
42
+ * Convert a glob pattern with `*` wildcards into a RegExp.
43
+ * Escape all regex meta-characters except `*`, which becomes `.*`.
44
+ * Anchored (^...$) and case-insensitive.
45
+ */
46
+ export function patternToRegExp(pattern: string): RegExp {
47
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
48
+ return new RegExp(`${escaped.replace(/\*/g, ".*")}`, "i");
49
+ }
50
+
51
+ /**
52
+ * Does a model id match a single allowlist pattern?
53
+ * Semantics (in order):
54
+ * 1. Exact case-insensitive match.
55
+ * 2. Glob match (pattern contains `*`).
56
+ * 3. Case-insensitive substring match (pi's fallback).
57
+ * Returns true on first hit; false otherwise.
58
+ */
59
+ export function matchesModelPattern(modelId: string, pattern: string): boolean {
60
+ if (!modelId || !pattern) return false;
61
+ const id = modelId.trim();
62
+ const pat = pattern.trim();
63
+ if (!id || !pat) return false;
64
+ if (id.toLowerCase() === pat.toLowerCase()) return true;
65
+ if (pat.includes("*")) {
66
+ try {
67
+ return patternToRegExp(pat).test(id);
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+ return id.toLowerCase().includes(pat.toLowerCase());
73
+ }
74
+
75
+ /**
76
+ * Is the model id accepted by ANY of the allowlist patterns?
77
+ * Returns false when patterns is empty/undefined (caller treats as "no scope").
78
+ */
79
+ export function isModelInScope(modelId: string | undefined, patterns: readonly string[] | undefined): boolean {
80
+ if (!modelId || !patterns || patterns.length === 0) return false;
81
+ return patterns.some((p) => matchesModelPattern(modelId, p));
82
+ }
83
+
84
+ /**
85
+ * Check a model against the allowlist and return a verdict.
86
+ * Returns `inScope: true` with no `reason` when no allowlist is configured
87
+ * (so callers can no-op cleanly).
88
+ */
89
+ export function checkModelScope(
90
+ modelId: string | undefined,
91
+ patterns: readonly string[] | undefined,
92
+ source: ModelScopeSource,
93
+ ): ModelScopeCheck {
94
+ if (!modelId) {
95
+ return { inScope: true, source, model: "", reason: "no model specified" };
96
+ }
97
+ if (!patterns || patterns.length === 0) {
98
+ // No allowlist → not enforcing. The toggle is opt-in; the user hasn't
99
+ // configured `enabledModels` so there is nothing to enforce against.
100
+ return { inScope: true, source, model: modelId };
101
+ }
102
+ for (const pattern of patterns) {
103
+ if (matchesModelPattern(modelId, pattern)) {
104
+ return { inScope: true, source, model: modelId, matchedPattern: pattern };
105
+ }
106
+ }
107
+ return {
108
+ inScope: false,
109
+ source,
110
+ model: modelId,
111
+ reason: `model "${modelId}" is not in enabledModels allowlist (${patterns.join(", ")})`,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Read the user's `enabledModels` allowlist from pi's SettingsManager.
117
+ * Returns an empty array when the SettingsManager export is unavailable, the
118
+ * allowlist is unset, or any error occurs (best-effort, never throws). The
119
+ * caller should still gate on `runtime.reliability.scopeModels` — an empty
120
+ * patterns array is a no-op (nothing to enforce against).
121
+ *
122
+ * @internal Only the runtime spawn layers should call this. Pure module: pure
123
+ * function over a cwd + optional agentDir.
124
+ */
125
+ export async function readEnabledModelsPatterns(cwd: string, agentDir?: string): Promise<string[]> {
126
+ try {
127
+ // Match the pattern live-session-runtime.ts:428 uses to bridge to pi's
128
+ // SDK. SettingsManager is dynamically imported because the module
129
+ // shape differs across pi versions; the create() factory is the
130
+ // canonical, version-stable entry point.
131
+ const mod = await import("@earendil-works/pi-coding-agent" as string).catch(() => null);
132
+ if (!mod) return [];
133
+ const SettingsManagerCtor = (mod as { SettingsManager?: { create?: (cwd: string, agentDir?: string) => { getEnabledModels?: () => string[] | undefined } } }).SettingsManager;
134
+ if (!SettingsManagerCtor?.create) return [];
135
+ const sm = SettingsManagerCtor.create(cwd, agentDir);
136
+ const patterns = sm.getEnabledModels?.();
137
+ return Array.isArray(patterns) ? patterns : [];
138
+ } catch {
139
+ return [];
140
+ }
141
+ }
@@ -3,7 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import type { AgentConfig } from "../agents/agent-config.ts";
6
- import { getAgentSessionOptions } from "../agents/agent-config.ts";
6
+ import { resolveToolPolicy } from "../agents/agent-config.ts";
7
7
  import { userPiRoot } from "../utils/paths.ts";
8
8
 
9
9
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
@@ -257,10 +257,17 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
257
257
  }
258
258
 
259
259
  // Apply role-based tool restrictions (from role-tools.ts)
260
- // Role-specific config takes precedence over agent-defined tools
261
- const toolConfig = input.role ? getAgentSessionOptions(input.role) : {};
262
- const explicitTools = toolConfig.tools ?? input.agent.tools;
263
- const excludeTools = toolConfig.excludeTools;
260
+ // F1 unify (v0.8.0): the tool policy is resolved by the shared
261
+ // `resolveToolPolicy` helper (same code as the live-session path), so the
262
+ // two spawn paths agree. Before this, child-pi used role-config
263
+ // authoritative and ignored `agent.disallowedTools`; live-session used
264
+ // frontmatter authoritative and ignored role-config. Now:
265
+ // - allowlist precedence is source-aware (builtin → role authoritative;
266
+ // user/project → frontmatter authoritative)
267
+ // - denylist is additive (role excludeTools + agent disallowedTools merged)
268
+ const policy = resolveToolPolicy(input.agent, input.role);
269
+ const explicitTools = policy.tools;
270
+ const excludeTools = policy.excludeTools;
264
271
 
265
272
  if (explicitTools?.length) args.push("--tools", explicitTools.join(","));
266
273
  if (excludeTools?.length) args.push("--exclude-tools", excludeTools.join(","));
@@ -268,7 +275,15 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
268
275
  // User extensions in ~/.pi/agent/extensions/ may fail due to missing dependencies.
269
276
  args.push("--no-extensions");
270
277
  if (input.agent.extensions !== undefined) {
271
- for (const extension of [PROMPT_RUNTIME_EXTENSION_PATH, ...input.agent.extensions]) args.push("--extension", extension);
278
+ // F1 (v0.7.9): apply `excludeExtensions` denylist (case-insensitive
279
+ // basename match) BEFORE the trusted PROMPT_RUNTIME_EXTENSION_PATH is
280
+ // prepended. The prompt-runtime is a pi-crew internal and is never
281
+ // excludable. Unknown names in the denylist are tolerated (logged
282
+ // would be nice but this path is sync and minimal — keeping parity
283
+ // with the rest of the agent loader's best-effort semantics).
284
+ const excluded = new Set((input.agent.excludeExtensions ?? []).map((name) => path.basename(name).toLowerCase()));
285
+ const allowed = input.agent.extensions.filter((ext) => !excluded.has(path.basename(ext).toLowerCase()));
286
+ for (const extension of [PROMPT_RUNTIME_EXTENSION_PATH, ...allowed]) args.push("--extension", extension);
272
287
  } else {
273
288
  args.push("--extension", PROMPT_RUNTIME_EXTENSION_PATH);
274
289
  }
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
+ import { execSync } from "node:child_process";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import * as path from "node:path";
5
6
 
@@ -118,6 +119,63 @@ function findPiPackageJsonFrom(startDir: string): string | undefined {
118
119
  return undefined;
119
120
  }
120
121
 
122
+ /**
123
+ * Discover the real npm global node_modules directory at runtime.
124
+ *
125
+ * Why this exists (Issue #33): on Windows, pi may be installed somewhere
126
+ * other than %APPDATA%\npm — e.g. nvm-windows puts the global node_modules
127
+ * under %NVM_HOME%/<version>/node_modules, Volta under
128
+ * %LOCALAPPDATA%\Volta, fnm under %LOCALAPPDATA%\fnm_multishells. The static
129
+ * %APPDATA%\npm paths in resolvePiCliScript() miss all of those, and the
130
+ * fallback spawn("pi") then fails with ENOENT because child_process.spawn does
131
+ * NOT do PATHEXT resolution on Windows (only exec/execSync via cmd.exe do).
132
+ *
133
+ * `npm root -g` is the canonical way to find the global node_modules dir and
134
+ * works across every npm-based install layout. We run it via execSync, which
135
+ * DOES resolve `npm.cmd` through PATHEXT. Capped at 5s; any failure (npm not
136
+ * on PATH, slow start, etc.) just falls through to the other resolution roots.
137
+ *
138
+ * Memoized: the npm global root does not change during a process lifetime, so
139
+ * this is a one-time ~200ms cost rather than per-worker.
140
+ *
141
+ * @internal — exported for unit-test injection via __setNpmGlobalRootForTest.
142
+ */
143
+ let cachedNpmGlobalRoot: string | undefined | null = null;
144
+ export function resolveNpmGlobalRoot(): string | undefined {
145
+ if (cachedNpmGlobalRoot !== null) {
146
+ return cachedNpmGlobalRoot ?? undefined;
147
+ }
148
+ let resolved: string | undefined;
149
+ try {
150
+ const out = execSync("npm root -g", {
151
+ encoding: "utf-8",
152
+ timeout: 5000,
153
+ stdio: ["pipe", "pipe", "pipe"], // suppress npm's stderr chatter
154
+ windowsHide: true,
155
+ }).trim();
156
+ resolved = out.length > 0 ? out : undefined;
157
+ } catch {
158
+ resolved = undefined;
159
+ }
160
+ cachedNpmGlobalRoot = resolved ?? null;
161
+ return resolved;
162
+ }
163
+
164
+ /**
165
+ * Given an npm global node_modules root, derive the candidate package dirs for
166
+ * each supported pi scope. Pure + exported so the mapping is unit-testable
167
+ * without spawning npm.
168
+ * @internal
169
+ */
170
+ export function buildNpmGlobalPackageDirs(npmGlobalRoot: string): string[] {
171
+ return PI_PACKAGE_NAMES.map((pkgName) => path.join(npmGlobalRoot, ...pkgName.split("/")));
172
+ }
173
+
174
+ /** @internal — test hook: inject a fake global root (or undefined) and reset the memo. */
175
+ export function __setNpmGlobalRootForTest(root: string | undefined): void {
176
+ cachedNpmGlobalRoot = root ?? null;
177
+ }
178
+
121
179
  function resolvePiCliScript(): string | undefined {
122
180
  const argv1 = process.argv[1];
123
181
  if (argv1) {
@@ -125,8 +183,16 @@ function resolvePiCliScript(): string | undefined {
125
183
  if (isRunnableNodeScript(argvPath)) return argvPath;
126
184
  }
127
185
 
186
+ // npm-global package dirs derived from `npm root -g` — placed BEFORE the
187
+ // %APPDATA%\npm static paths and the cwd/import.meta fallbacks so that a pi
188
+ // install under nvm-windows / Volta / fnm is found even when %APPDATA%\npm
189
+ // doesn't contain it. Covers Issue #33.
190
+ const npmGlobalRoot = resolveNpmGlobalRoot();
191
+ const npmGlobalDirs = npmGlobalRoot ? buildNpmGlobalPackageDirs(npmGlobalRoot) : [];
192
+
128
193
  const roots = [
129
194
  resolvePiPackageRoot(),
195
+ ...npmGlobalDirs,
130
196
  process.env.APPDATA ? path.join(process.env.APPDATA, "npm", "node_modules", "@earendil-works", "pi-coding-agent") : undefined,
131
197
  process.env.APPDATA ? path.join(process.env.APPDATA, "npm", "node_modules", "@mariozechner", "pi-coding-agent") : undefined,
132
198
  path.dirname(fileURLToPath(import.meta.url)),