pi-crew 0.7.7 → 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.
- package/CHANGELOG.md +269 -0
- package/package.json +1 -1
- package/src/agents/agent-config.ts +101 -1
- package/src/agents/discover-agents.ts +34 -3
- package/src/config/types.ts +8 -0
- package/src/errors.ts +9 -0
- package/src/extension/context-status-injection.ts +14 -5
- package/src/extension/register.ts +4 -18
- package/src/extension/registration/compaction-guard.ts +44 -13
- package/src/extension/team-tool/handle-settings.ts +2 -0
- package/src/runtime/live-session-runtime.ts +69 -7
- package/src/runtime/model-fallback.ts +39 -1
- package/src/runtime/model-scope.ts +141 -0
- package/src/runtime/pi-args.ts +21 -6
- package/src/runtime/skill-instructions.ts +14 -4
- package/src/runtime/task-runner.ts +21 -0
- package/src/skills/discover-skills.ts +31 -2
- package/src/ui/agent-management-overlay.ts +1 -1
- package/src/utils/session-utils.ts +30 -0
|
@@ -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
|
-
//
|
|
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
|
|
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
|
|
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",
|
|
@@ -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
|
-
|
|
305
|
-
|
|
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
|
|
373
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/runtime/pi-args.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -21,6 +21,8 @@ const PACKAGE_SKILLS_DIR = path.resolve(
|
|
|
21
21
|
"..",
|
|
22
22
|
"skills",
|
|
23
23
|
);
|
|
24
|
+
import * as os from "node:os";
|
|
25
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
24
26
|
const MAX_SKILL_CHARS = 1500;
|
|
25
27
|
const MAX_TOTAL_CHARS = 6000;
|
|
26
28
|
const MAX_SKILL_NAME_CHARS = 80;
|
|
@@ -139,16 +141,24 @@ export function resolveTaskSkillNames(input: ResolveTaskSkillsInput): string[] {
|
|
|
139
141
|
|
|
140
142
|
function candidateSkillDirs(
|
|
141
143
|
cwd: string,
|
|
142
|
-
): Array<{ root: string; source: "project" | "package" }> {
|
|
144
|
+
): Array<{ root: string; source: "project" | "package" | "project-pi" | "user-pi" | "project-agents" | "user-agents" }> {
|
|
143
145
|
return [
|
|
144
146
|
{ root: PACKAGE_SKILLS_DIR, source: "package" }, // ✓ Trusted first
|
|
145
|
-
|
|
147
|
+
// F6 (v0.7.9): same five roots as discover-skills, in the same precedence
|
|
148
|
+
// order. The first hit wins, so a project `.pi/skills/foo/SKILL.md`
|
|
149
|
+
// overrides both the bundled `foo` and any legacy `<cwd>/skills/foo`.
|
|
150
|
+
{ root: path.resolve(cwd, ".pi", "skills"), source: "project-pi" },
|
|
151
|
+
{ root: path.resolve(cwd, ".agents", "skills"), source: "project-agents" },
|
|
152
|
+
{ root: path.resolve(cwd, "skills"), source: "project" },
|
|
153
|
+
{ root: path.join(getAgentDir(), "skills"), source: "user-pi" },
|
|
154
|
+
{ root: path.join(os.homedir(), ".agents", "skills"), source: "user-agents" },
|
|
155
|
+
{ root: path.join(os.homedir(), ".pi", "skills"), source: "user-pi" },
|
|
146
156
|
];
|
|
147
157
|
}
|
|
148
158
|
|
|
149
159
|
interface CachedSkillMarkdown {
|
|
150
160
|
path: string;
|
|
151
|
-
source: "project" | "package";
|
|
161
|
+
source: "project" | "package" | "project-pi" | "user-pi" | "project-agents" | "user-agents";
|
|
152
162
|
content: string;
|
|
153
163
|
mtimeMs: number;
|
|
154
164
|
size: number;
|
|
@@ -187,7 +197,7 @@ function readSkillMarkdown(
|
|
|
187
197
|
cwd: string,
|
|
188
198
|
name: string,
|
|
189
199
|
):
|
|
190
|
-
| { path: string; source: "project" | "package"; content: string }
|
|
200
|
+
| { path: string; source: "project" | "package" | "project-pi" | "user-pi" | "project-agents" | "user-agents"; content: string }
|
|
191
201
|
| undefined {
|
|
192
202
|
if (!isValidSkillName(name)) return undefined;
|
|
193
203
|
const cacheKey = `${path.resolve(cwd)}:${name}`;
|
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
isRetryableModelFailure,
|
|
33
33
|
type ModelAttemptSummary,
|
|
34
34
|
} from "./model-fallback.ts";
|
|
35
|
+
import { readEnabledModelsPatterns } from "./model-scope.ts";
|
|
36
|
+
import { loadConfig } from "../config/config.ts";
|
|
35
37
|
import { tailReadWithLineSnap } from "./task-runner/tail-read.ts";
|
|
36
38
|
import {
|
|
37
39
|
parsePiJsonOutput,
|
|
@@ -385,6 +387,7 @@ export async function runTeamTask(
|
|
|
385
387
|
parentModel: input.parentModel,
|
|
386
388
|
modelRegistry: input.modelRegistry,
|
|
387
389
|
cwd: task.cwd,
|
|
390
|
+
scopeModelsPatterns: await resolveTaskScopeModelsPatterns(task.cwd),
|
|
388
391
|
});
|
|
389
392
|
const candidates = modelRoutingPlan.candidates;
|
|
390
393
|
const attemptModels =
|
|
@@ -1306,3 +1309,21 @@ export async function runTeamTask(
|
|
|
1306
1309
|
streamBridge?.dispose();
|
|
1307
1310
|
}
|
|
1308
1311
|
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* F7: resolve the enabledModels allowlist for the child-process spawn path,
|
|
1315
|
+
* but only if `runtime.reliability.scopeModels` is ON. Returns [] (no-op)
|
|
1316
|
+
* when the toggle is off or the allowlist is empty. Best-effort: any failure
|
|
1317
|
+
* to read config or the allowlist silently disables the gate so spawn is
|
|
1318
|
+
* never blocked by a misconfiguration.
|
|
1319
|
+
*/
|
|
1320
|
+
async function resolveTaskScopeModelsPatterns(cwd: string): Promise<string[]> {
|
|
1321
|
+
let scopeModels = false;
|
|
1322
|
+
try {
|
|
1323
|
+
scopeModels = loadConfig(cwd).config.reliability?.scopeModels === true;
|
|
1324
|
+
} catch {
|
|
1325
|
+
return [];
|
|
1326
|
+
}
|
|
1327
|
+
if (!scopeModels) return [];
|
|
1328
|
+
return readEnabledModelsPatterns(cwd);
|
|
1329
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
6
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
5
7
|
import { isSafePathId, resolveContainedPath, resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
6
8
|
|
|
@@ -12,13 +14,40 @@ let cache: { skills: SkillDescriptor[]; cachedAt: number; cwd: string } | null =
|
|
|
12
14
|
export interface SkillDescriptor {
|
|
13
15
|
name: string;
|
|
14
16
|
description: string;
|
|
15
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Source of the skill. F6 (v0.7.9) adds the Agent Skills spec roots:
|
|
19
|
+
* - `project-pi` / `user-pi` — Pi's standard `.pi/skills/`
|
|
20
|
+
* - `project-agents` / `user-agents` — cross-tool Agent Skills spec (`.agents/skills/`)
|
|
21
|
+
* The original `project` / `package` are kept for back-compat.
|
|
22
|
+
*/
|
|
23
|
+
source: "project" | "package" | "project-pi" | "user-pi" | "project-agents" | "user-agents";
|
|
16
24
|
path: string;
|
|
17
25
|
}
|
|
18
26
|
|
|
19
|
-
|
|
27
|
+
/**
|
|
28
|
+
* F6 (v0.7.9): discover skills from all five roots (matching pi-subagents'
|
|
29
|
+
* skill-loader so users authoring skills under either convention find them).
|
|
30
|
+
* Roots, in precedence order (first hit wins):
|
|
31
|
+
* 1. <cwd>/.pi/skills (project, Pi standard)
|
|
32
|
+
* 2. <cwd>/.agents/skills (project, Agent Skills spec — agentskills.io)
|
|
33
|
+
* 3. <cwd>/skills (project, legacy pi-crew convention)
|
|
34
|
+
* 4. <getAgentDir>/skills (user, Pi standard)
|
|
35
|
+
* 5. <homedir>/.agents/skills (user, Agent Skills spec)
|
|
36
|
+
* 6. <homedir>/.pi/skills (user, legacy Pi — pre-standard)
|
|
37
|
+
* 7. PACKAGE_SKILLS_DIR (bundled, trusted)
|
|
38
|
+
* The `PACKAGE_SKILLS_DIR` (bundled) and the legacy `<cwd>/skills` root are
|
|
39
|
+
* kept as separate `source` values to preserve the existing capability
|
|
40
|
+
* inventory shape — callers that key on `source === "package"` / `source ===
|
|
41
|
+
* "project"` keep working.
|
|
42
|
+
*/
|
|
43
|
+
function listSkillDirs(cwd: string): Array<{ root: string; source: SkillDescriptor["source"] }> {
|
|
20
44
|
return [
|
|
45
|
+
{ root: path.resolve(cwd, ".pi", "skills"), source: "project-pi" },
|
|
46
|
+
{ root: path.resolve(cwd, ".agents", "skills"), source: "project-agents" },
|
|
21
47
|
{ root: path.resolve(cwd, "skills"), source: "project" },
|
|
48
|
+
{ root: path.join(getAgentDir(), "skills"), source: "user-pi" },
|
|
49
|
+
{ root: path.join(os.homedir(), ".agents", "skills"), source: "user-agents" },
|
|
50
|
+
{ root: path.join(os.homedir(), ".pi", "skills"), source: "user-pi" },
|
|
22
51
|
{ root: PACKAGE_SKILLS_DIR, source: "package" },
|
|
23
52
|
];
|
|
24
53
|
}
|