pi-subagents 0.52.1 → 0.54.0

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 (67) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +4 -0
  3. package/docs/configuration.md +12 -2
  4. package/docs/extension-api.md +3 -1
  5. package/docs/models.md +17 -3
  6. package/docs/workflows.md +2 -0
  7. package/index.ts +10 -1
  8. package/package.json +2 -1
  9. package/prompts/council.md +51 -0
  10. package/skills/council-mode/SKILL.md +231 -0
  11. package/skills/pi-subagents/SKILL.md +5 -1
  12. package/skills/pi-subagents/references/constraints-and-recipes.md +1 -0
  13. package/skills/pi-subagents/references/execution-controls.md +13 -0
  14. package/skills/pi-subagents/references/prompting-and-roles.md +7 -0
  15. package/src/agents/agent-management.ts +107 -8
  16. package/src/agents/agent-serializer.ts +2 -0
  17. package/src/agents/agents.ts +96 -37
  18. package/src/agents/builtin-names.ts +9 -0
  19. package/src/agents/runtime-agent-registry.ts +418 -0
  20. package/src/api/agents.ts +7 -0
  21. package/src/api/preflight.ts +8 -3
  22. package/src/extension/config.ts +3 -0
  23. package/src/extension/doctor.ts +11 -0
  24. package/src/extension/fanout-child.ts +3 -2
  25. package/src/extension/index.ts +20 -4
  26. package/src/extension/public-execution.ts +1 -1
  27. package/src/extension/rpc.ts +41 -1
  28. package/src/extension/schemas.ts +6 -3
  29. package/src/extension/tool-description.ts +2 -2
  30. package/src/extension/tool-result.ts +19 -0
  31. package/src/inspectors/herdr/client.ts +3 -3
  32. package/src/runs/background/async-execution.ts +12 -6
  33. package/src/runs/background/async-job-tracker.ts +4 -3
  34. package/src/runs/background/async-resume.ts +2 -1
  35. package/src/runs/background/async-retention.ts +1 -1
  36. package/src/runs/background/async-status-snapshot.ts +14 -5
  37. package/src/runs/background/auto-drain.ts +1 -0
  38. package/src/runs/background/chain-root-attachment.ts +5 -0
  39. package/src/runs/background/result-watcher.ts +9 -0
  40. package/src/runs/background/stale-run-reconciler.ts +3 -0
  41. package/src/runs/background/subagent-runner.ts +32 -5
  42. package/src/runs/background/subagent-wait.ts +9 -5
  43. package/src/runs/background/terminal-run-index.ts +15 -6
  44. package/src/runs/background/wait-completions.ts +2 -0
  45. package/src/runs/background/wait-tool.ts +5 -3
  46. package/src/runs/foreground/execution.ts +34 -2
  47. package/src/runs/foreground/subagent-executor.ts +196 -52
  48. package/src/runs/foreground/workflow-detach-reconcile.ts +83 -15
  49. package/src/runs/shared/acceptance.ts +44 -1
  50. package/src/runs/shared/model-exclusions.ts +242 -0
  51. package/src/runs/shared/model-fallback.ts +72 -16
  52. package/src/runs/shared/model-scope.ts +106 -39
  53. package/src/runs/shared/pi-args.ts +34 -1
  54. package/src/runs/shared/subagent-control.ts +25 -3
  55. package/src/runs/shared/subagent-prompt-runtime.ts +36 -9
  56. package/src/shared/fork-context.ts +17 -1
  57. package/src/shared/model-info.ts +20 -0
  58. package/src/shared/settings.ts +2 -2
  59. package/src/shared/types.ts +47 -2
  60. package/src/slash/slash-commands.ts +20 -6
  61. package/src/slash/slash-live-state.ts +3 -3
  62. package/src/tui/fleet-status.ts +86 -1
  63. package/src/tui/fleet.ts +55 -2
  64. package/src/tui/render.ts +73 -3
  65. package/src/watchdog/permission-arbiter.ts +59 -51
  66. package/src/workflows/scripted-workflow.ts +100 -12
  67. package/src/workflows/workflow-receipt.ts +140 -0
@@ -1,7 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
4
- import { isStorageCapacityError } from "../../shared/file-system-retry.ts";
5
4
  import { getSingleResultOutput, readStatus } from "../../shared/utils.ts";
6
5
  import {
7
6
  DIRS,
@@ -13,6 +12,8 @@ import {
13
12
  } from "../../shared/types.ts";
14
13
  import { updateActiveRunIndex } from "../background/active-run-index.ts";
15
14
  import { resultFilePath, writeAsyncResultFile } from "../background/result-files.ts";
15
+ import { resolveAsyncResumeTarget } from "../background/async-resume.ts";
16
+ import { readWorkflowReceipt, workflowReceiptPath, writeWorkflowReceipt, type WorkflowReceipt } from "../../workflows/workflow-receipt.ts";
16
17
 
17
18
  function cloneWorkflowStatus(status: AsyncStatus): AsyncStatus {
18
19
  return {
@@ -97,7 +98,7 @@ function workflowResultChildren(status: AsyncStatus, childRunId: string, result:
97
98
  }));
98
99
  }
99
100
 
100
- function publishedWorkflowResult(status: AsyncStatus, childRunId: string, result: SingleResult, asyncDir: string, existing?: Record<string, unknown>): Record<string, unknown> {
101
+ function publishedWorkflowResult(status: AsyncStatus, childRunId: string, result: SingleResult, asyncDir: string, existing?: Record<string, unknown>, receipt?: WorkflowReceipt): Record<string, unknown> {
101
102
  const sessionId = status.sessionId ?? (typeof existing?.sessionId === "string" ? existing.sessionId : undefined);
102
103
  const summary = status.state === "complete"
103
104
  ? `Workflow completed after detached child ${childRunId} finished.`
@@ -118,6 +119,7 @@ function publishedWorkflowResult(status: AsyncStatus, childRunId: string, result
118
119
  timestamp: Date.now(),
119
120
  results: workflowResultChildren(status, childRunId, result, existing?.results),
120
121
  workflow: status.workflow,
122
+ ...(receipt ? { workflowReceipt: { path: path.join(asyncDir, "workflow-receipt.json"), receipt } } : {}),
121
123
  asyncDir,
122
124
  cwd: status.cwd,
123
125
  sessionId,
@@ -125,6 +127,60 @@ function publishedWorkflowResult(status: AsyncStatus, childRunId: string, result
125
127
  };
126
128
  }
127
129
 
130
+ function reconcileWorkflowReceipt(status: AsyncStatus, childRunId: string, result: SingleResult, asyncDir: string): WorkflowReceipt | undefined {
131
+ const receiptPath = workflowReceiptPath(DIRS.async, status.runId);
132
+ if (!fs.existsSync(receiptPath)) return undefined;
133
+ const receipt = readWorkflowReceipt(DIRS.async, status.runId);
134
+ const step = status.steps?.find((candidate) => candidate.runId === childRunId);
135
+ const key = step?.workflowKey;
136
+ if (!key) throw new Error(`Workflow receipt '${status.runId}' cannot identify detached child '${childRunId}' by stable key.`);
137
+ const entry = receipt.entries[key];
138
+ if (!entry) throw new Error(`Workflow receipt '${status.runId}' has no detached child key '${key}'.`);
139
+ let resumability: typeof entry.resumability;
140
+ try {
141
+ const target = resolveAsyncResumeTarget({ id: childRunId, dir: path.join(DIRS.async, childRunId) }, {}, { requireSessionFile: true, sessionId: status.sessionId });
142
+ resumability = target.kind === "revive" ? { state: "resumable" } : { state: "not-resumable", reason: "child is still running" };
143
+ } catch (error) {
144
+ resumability = { state: "not-resumable", reason: error instanceof Error ? error.message : String(error) };
145
+ }
146
+ const outputReference = result.savedOutputPath ?? result.outputReference?.path ?? entry.outputReference;
147
+ const updatedEntry: WorkflowReceipt["entries"][string] = resumability.state === "resumable"
148
+ ? {
149
+ ...entry,
150
+ ...(step.agent ? { agent: step.agent } : {}),
151
+ ...(step.context ? { resolvedContext: step.context } : {}),
152
+ latestRunId: entry.latestRunId ?? childRunId,
153
+ resumability,
154
+ ...(outputReference ? { outputReference } : {}),
155
+ }
156
+ : {
157
+ ...entry,
158
+ ...(step.agent ? { agent: step.agent } : {}),
159
+ ...(step.context ? { resolvedContext: step.context } : {}),
160
+ resumability,
161
+ ...(outputReference ? { outputReference } : {}),
162
+ };
163
+ const next: WorkflowReceipt = {
164
+ ...receipt,
165
+ state: status.state === "complete" ? "complete" : status.state === "stopped" ? "stopped" : status.state === "paused" ? "paused" : "failed",
166
+ entries: {
167
+ ...receipt.entries,
168
+ [key]: updatedEntry,
169
+ },
170
+ };
171
+ writeWorkflowReceipt(asyncDir, next);
172
+ return next;
173
+ }
174
+
175
+ function appendDetachedWorkflowEvent(asyncDir: string, event: Record<string, unknown>): void {
176
+ const eventsPath = path.join(asyncDir, "events.jsonl");
177
+ try {
178
+ fs.appendFileSync(eventsPath, `${JSON.stringify(event)}\n`, "utf-8");
179
+ } catch (error) {
180
+ console.error(`Failed to append detached workflow event '${eventsPath}':`, error);
181
+ }
182
+ }
183
+
128
184
  export function reconcileDetachedWorkflowChildCompletion(input: {
129
185
  state: SubagentState;
130
186
  workflowRunId: string;
@@ -160,21 +216,33 @@ export function reconcileDetachedWorkflowChildCompletion(input: {
160
216
  } catch (error) {
161
217
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
162
218
  }
163
- const published = publishedWorkflowResult(next, input.childRunId, input.result, asyncDir, existing);
219
+ let receipt: WorkflowReceipt | undefined;
220
+ let receiptError: string | undefined;
221
+ try {
222
+ receipt = reconcileWorkflowReceipt(next, input.childRunId, input.result, asyncDir);
223
+ } catch (error) {
224
+ receiptError = `Failed to reconcile async workflow receipt: ${error instanceof Error ? error.message : String(error)}`;
225
+ }
226
+ const published = publishedWorkflowResult(next, input.childRunId, input.result, asyncDir, existing, receipt);
164
227
  writeAsyncResultFile(resultPath, published);
228
+ if (receiptError) {
229
+ appendDetachedWorkflowEvent(asyncDir, {
230
+ ts: Date.now(),
231
+ runId: input.workflowRunId,
232
+ type: "subagent.workflow.receipt_write_failed",
233
+ error: receiptError,
234
+ reconciledFromDetachedChild: input.childRunId,
235
+ });
236
+ }
165
237
  if (next.state === "complete" || next.state === "failed") {
166
- try {
167
- fs.appendFileSync(path.join(asyncDir, "events.jsonl"), `${JSON.stringify({
168
- ts: Date.now(),
169
- runId: input.workflowRunId,
170
- type: "subagent.workflow.completed",
171
- state: next.state,
172
- ...(next.error ? { error: next.error } : {}),
173
- reconciledFromDetachedChild: input.childRunId,
174
- })}\n`, "utf-8");
175
- } catch (error) {
176
- if (!isStorageCapacityError(error)) throw error;
177
- }
238
+ appendDetachedWorkflowEvent(asyncDir, {
239
+ ts: Date.now(),
240
+ runId: input.workflowRunId,
241
+ type: "subagent.workflow.completed",
242
+ state: next.state,
243
+ ...(next.error ? { error: next.error } : {}),
244
+ reconciledFromDetachedChild: input.childRunId,
245
+ });
178
246
  input.events?.emit(SUBAGENT_ASYNC_COMPLETE_EVENT, {
179
247
  id: input.workflowRunId,
180
248
  runId: input.workflowRunId,
@@ -1140,6 +1140,49 @@ async function runMemoizedVerifyCommand(command: AcceptanceVerifyCommand, defaul
1140
1140
  return evidenced;
1141
1141
  }
1142
1142
 
1143
+ /**
1144
+ * On Windows with `shell: true`, cmd.exe parses the command line itself and an
1145
+ * unquoted executable path containing spaces (e.g. `C:\Program Files\...\tool.exe`)
1146
+ * is split at the first space, so cmd tries to run `C:\Program` and fails.
1147
+ *
1148
+ * The command line is ambiguous, so only an unquoted absolute drive path with
1149
+ * a space in a directory component is safe to identify as an executable. That
1150
+ * path is quoted; everything after it is preserved as arguments.
1151
+ *
1152
+ * Commands that already start with a quote, single-token commands, and commands
1153
+ * whose first token already ends in an executable extension are returned
1154
+ * unchanged. Non-Windows platforms pass the command through untouched.
1155
+ */
1156
+ export function quoteExecutableForShell(command: string, platform: string = process.platform): string {
1157
+ if (platform !== "win32") return command;
1158
+ const trimmed = command.trimStart();
1159
+ if (trimmed.startsWith("\"")) return command;
1160
+ const firstToken = trimmed.match(/^\S+/)?.[0];
1161
+ if (/\.(?:exe|bat|cmd|com|ps1)$/i.test(firstToken ?? "")) return command;
1162
+ const match = trimmed.match(/^([A-Za-z]:\\[^"<>|&*?\r\n]*?\s[^"<>|&*?\r\n]*?\\[^"<>|&*?\r\n]*?\.(?:exe|bat|cmd|com|ps1))(?=\s|$)/i);
1163
+ const executable = match?.[1];
1164
+ if (executable && /\s/.test(executable) && !/[A-Za-z]:\\/.test(executable.slice(3))) {
1165
+ const rest = trimmed.slice(executable.length);
1166
+ const leading = command.slice(0, command.length - trimmed.length);
1167
+ return `${leading}"${executable}"${rest}`;
1168
+ }
1169
+ const extensionlessSpacedFilenameMatch = trimmed.match(/^([A-Za-z]:\\[^"<>|&*?\r\n]*?\s[^"<>|&*?\r\n]*?)(?=\s+--|$)/i);
1170
+ const extensionlessSpacedFilename = extensionlessSpacedFilenameMatch?.[1];
1171
+ if (extensionlessSpacedFilename && /\s/.test(extensionlessSpacedFilename.slice(0, extensionlessSpacedFilename.lastIndexOf("\\"))) && !/[A-Za-z]:\\/.test(extensionlessSpacedFilename.slice(3))) {
1172
+ const rest = trimmed.slice(extensionlessSpacedFilename.length);
1173
+ const leading = command.slice(0, command.length - trimmed.length);
1174
+ return `${leading}"${extensionlessSpacedFilename}"${rest}`;
1175
+ }
1176
+ const extensionlessMatch = trimmed.match(/^([A-Za-z]:\\[^"<>|&*?\r\n]*?\s[^"<>|&*?\r\n]*?\\[^"<>|&*?\s\r\n]+)(?=\s|$)/i);
1177
+ const extensionlessExecutable = extensionlessMatch?.[1];
1178
+ if (extensionlessExecutable && /\s/.test(extensionlessExecutable) && !/[A-Za-z]:\\/.test(extensionlessExecutable.slice(3)) && !/\s/.test(extensionlessExecutable.slice(extensionlessExecutable.lastIndexOf("\\") + 1))) {
1179
+ const rest = trimmed.slice(extensionlessExecutable.length);
1180
+ const leading = command.slice(0, command.length - trimmed.length);
1181
+ return `${leading}"${extensionlessExecutable}"${rest}`;
1182
+ }
1183
+ return command;
1184
+ }
1185
+
1143
1186
  function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string, options: { signal?: AbortSignal; abortMessage?: string } = {}): Promise<AcceptanceVerifyResult> {
1144
1187
  return new Promise((resolve) => {
1145
1188
  const startedAt = Date.now();
@@ -1149,7 +1192,7 @@ function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string,
1149
1192
  let timedOut = false;
1150
1193
  let settled = false;
1151
1194
  let hardKill: NodeJS.Timeout | undefined;
1152
- const child = spawn(command.command, {
1195
+ const child = spawn(quoteExecutableForShell(command.command), {
1153
1196
  cwd,
1154
1197
  env: effectiveVerifyEnv(command.env),
1155
1198
  shell: true,
@@ -0,0 +1,242 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { TEMP_ROOT_DIR } from "../../shared/types.ts";
4
+
5
+ export const EXCLUSIONS_PATH_ENV = "PI_MODEL_EXCLUSIONS_PATH";
6
+
7
+ type ModelExclusionTarget = { modelId: string; provider?: string } | { provider: string; modelId?: never };
8
+
9
+ export type ModelExclusion = ModelExclusionTarget & {
10
+ reason?: string;
11
+ recordedAt: number;
12
+ expiresAt: number;
13
+ };
14
+
15
+ type RecordModelFailureOptions = ModelExclusionTarget & {
16
+ reason?: string;
17
+ ttlMs?: number;
18
+ };
19
+
20
+ let exclusions: ModelExclusion[] = [];
21
+ let loaded = false;
22
+ let defaultTTLMs = 24 * 60 * 60_000; // 24 hours, overridable via setDefaultTTL
23
+ let persistTimer: ReturnType<typeof setTimeout> | null = null;
24
+ let persistSeq = 0;
25
+
26
+ /** Override the default exclusion TTL. */
27
+ export function setDefaultTTL(ms: number): void {
28
+ if (!Number.isFinite(ms) || ms <= 0) throw new Error("Default model exclusion TTL must be a finite positive number.");
29
+ defaultTTLMs = ms;
30
+ }
31
+
32
+ /**
33
+ * Resolve the persistence path. Honors PI_MODEL_EXCLUSIONS_PATH; defaults to
34
+ * <TEMP_ROOT_DIR>/model-exclusions.json. Resolved lazily so tests can point the
35
+ * store at an isolated location after module load.
36
+ */
37
+ export function getExclusionsFilePath(): string {
38
+ const envPath = process.env[EXCLUSIONS_PATH_ENV];
39
+ if (typeof envPath === "string" && envPath.trim()) return envPath.trim();
40
+ return path.join(TEMP_ROOT_DIR, "model-exclusions.json");
41
+ }
42
+
43
+ /**
44
+ * Persist exclusions to disk immediately (atomic write via tmp + rename).
45
+ * The store otherwise debounces writes; call this when durability matters
46
+ * (and in tests).
47
+ */
48
+ export function flushPersist(): void {
49
+ const file = getExclusionsFilePath();
50
+ try {
51
+ fs.mkdirSync(path.dirname(file), { recursive: true });
52
+ const tmpPath = `${file}.${process.pid}.${persistSeq++}.tmp`;
53
+ fs.writeFileSync(tmpPath, JSON.stringify({
54
+ version: 1,
55
+ exclusions: deduplicate(exclusions),
56
+ }, null, 2), "utf-8");
57
+ fs.renameSync(tmpPath, file);
58
+ } catch (error) {
59
+ console.error(`[model-exclusions] Failed to persist exclusions to ${file}:`, error);
60
+ }
61
+ }
62
+
63
+ function schedulePersist(): void {
64
+ if (persistTimer) clearTimeout(persistTimer);
65
+ persistTimer = setTimeout(() => {
66
+ persistTimer = null;
67
+ flushPersist();
68
+ }, 5000);
69
+ // Never hold the process open just to flush exclusions.
70
+ persistTimer.unref?.();
71
+ }
72
+
73
+ function ensureLoaded(): void {
74
+ if (loaded) return;
75
+ loaded = true;
76
+ try {
77
+ const raw = fs.readFileSync(getExclusionsFilePath(), "utf-8");
78
+ const data = JSON.parse(raw);
79
+ if (data.version === 1) {
80
+ const now = Date.now();
81
+ exclusions = (data.exclusions ?? []).filter((e: ModelExclusion) => e.expiresAt > now);
82
+ exclusions = deduplicate(exclusions);
83
+ }
84
+ } catch (error) {
85
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
86
+ console.error(`[model-exclusions] Failed to load exclusions from ${getExclusionsFilePath()}:`, error);
87
+ }
88
+ }
89
+ }
90
+
91
+ function dedupKey(entry: ModelExclusion): string {
92
+ return `${entry.provider ?? ""}|${entry.modelId ?? ""}`;
93
+ }
94
+
95
+ function deduplicate(items: ModelExclusion[]): ModelExclusion[] {
96
+ const map = new Map<string, ModelExclusion>();
97
+ for (const entry of items) {
98
+ const key = dedupKey(entry);
99
+ const existing = map.get(key);
100
+ if (!existing || entry.recordedAt > existing.recordedAt) {
101
+ map.set(key, entry);
102
+ }
103
+ }
104
+ return Array.from(map.values());
105
+ }
106
+
107
+ /**
108
+ * Record a model failure as a temporary exclusion. While the exclusion is
109
+ * active, {@link isExcluded} returns true for the model (or for every model of
110
+ * the provider when modelId is omitted), and {@link filterFallbackCandidates}
111
+ * removes matching candidates from fallback lists.
112
+ */
113
+ export function recordModelFailure(options: RecordModelFailureOptions): void {
114
+ ensureLoaded();
115
+ const ttl = options.ttlMs ?? defaultTTLMs;
116
+ const now = Date.now();
117
+ const target: ModelExclusionTarget = options.modelId !== undefined
118
+ ? { modelId: options.modelId, ...(options.provider ? { provider: options.provider } : {}) }
119
+ : { provider: options.provider };
120
+ const exclusion: ModelExclusion = {
121
+ ...target,
122
+ reason: options.reason ?? "runtime-failure",
123
+ recordedAt: now,
124
+ expiresAt: now + ttl,
125
+ };
126
+ exclusions.unshift(exclusion);
127
+ exclusions = deduplicate(exclusions);
128
+ if (exclusions.length > 200) exclusions.length = 200;
129
+ flushPersist();
130
+ }
131
+
132
+ /**
133
+ * Drop all expired exclusions from memory and schedule a persist.
134
+ */
135
+ export function clearExpiredExclusions(): void {
136
+ ensureLoaded();
137
+ prune(exclusions, Date.now());
138
+ schedulePersist();
139
+ }
140
+
141
+ /**
142
+ * Remove every exclusion (e.g. after the operator fixes credentials).
143
+ */
144
+ export function clearExclusions(): void {
145
+ ensureLoaded();
146
+ exclusions.length = 0;
147
+ schedulePersist();
148
+ }
149
+
150
+ /**
151
+ * Whether an exclusion entry matches a candidate.
152
+ *
153
+ * Semantics:
154
+ * - Entry with modelId: model-specific exclusion. Matches only that modelId;
155
+ * when both the entry and the candidate carry a provider, the providers must
156
+ * also agree so `openai/gpt-4` does not exclude `github-copilot/gpt-4`.
157
+ * - Entry without modelId: provider-wide exclusion (e.g. quota or auth failure).
158
+ * Matches every model of that provider.
159
+ */
160
+ function entryMatches(entry: ModelExclusion, candidateModelId: string, candidateProvider: string | undefined, now: number): boolean {
161
+ if (entry.expiresAt <= now) return false;
162
+ if (entry.modelId) {
163
+ if (entry.modelId !== candidateModelId) return false;
164
+ return !entry.provider || !candidateProvider || entry.provider === candidateProvider;
165
+ }
166
+ return Boolean(entry.provider) && entry.provider === candidateProvider;
167
+ }
168
+
169
+ /**
170
+ * Whether a model (or its provider) is currently excluded.
171
+ */
172
+ export function isExcluded(modelId: string, provider: string): boolean {
173
+ ensureLoaded();
174
+ return exclusions.some((entry) => entryMatches(entry, modelId, provider, Date.now()));
175
+ }
176
+
177
+ /**
178
+ * Number of live (non-expired) exclusions.
179
+ */
180
+ export function getExcludedCount(): number {
181
+ ensureLoaded();
182
+ clearExpiredExclusions();
183
+ return exclusions.length;
184
+ }
185
+
186
+ /**
187
+ * Split a candidate fullId into its provider + modelId components.
188
+ *
189
+ * A fullId may carry a thinking suffix (`provider/model:thinking`) which is
190
+ * stripped before parsing, and the modelId itself may contain slashes
191
+ * (e.g. `openrouter/google/gemini-flash`). The first `/`-segment is the
192
+ * provider; everything after is the modelId. This MUST stay in lock-step with
193
+ * the matching inside {@link isExcluded} so that a failure recorded via
194
+ * {@link recordModelFailure} is later recognised by the candidate filter.
195
+ */
196
+ export function parseModelKey(fullId: string): { provider?: string; modelId: string } {
197
+ const base = fullId.includes(":") ? fullId.substring(0, fullId.lastIndexOf(":")) : fullId;
198
+ if (!base.includes("/")) return { modelId: base };
199
+ const slash = base.indexOf("/");
200
+ return { provider: base.slice(0, slash), modelId: base.slice(slash + 1) };
201
+ }
202
+
203
+ /**
204
+ * Filter a list of candidate fullIds, removing excluded models/providers and
205
+ * duplicates while preserving order.
206
+ */
207
+ export function filterFallbackCandidates(candidates: string[], opts?: { now?: number }): string[] {
208
+ ensureLoaded();
209
+ const timestamp = opts?.now ?? Date.now();
210
+ const seen = new Set<string>();
211
+ const filtered: string[] = [];
212
+ for (const raw of candidates) {
213
+ if (!raw || seen.has(raw)) continue;
214
+ const { provider: candidateProvider, modelId: candidateModelId } = parseModelKey(raw);
215
+ const excluded = exclusions.some((entry) => entryMatches(entry, candidateModelId, candidateProvider, timestamp));
216
+ if (excluded) continue;
217
+ seen.add(raw);
218
+ filtered.push(raw);
219
+ }
220
+ return filtered;
221
+ }
222
+
223
+ /**
224
+ * Reload exclusions from disk (for tests and config hot-reload).
225
+ * Discards any in-memory-only exclusions that were not yet persisted.
226
+ */
227
+ export function reloadFromDisk(): void {
228
+ loaded = false;
229
+ exclusions = [];
230
+ ensureLoaded();
231
+ }
232
+
233
+ function prune(items: ModelExclusion[], now: number): void {
234
+ let write = 0;
235
+ for (let i = 0; i < items.length; i++) {
236
+ const entry = items[i]!;
237
+ if (entry.expiresAt > now) {
238
+ items[write++] = entry;
239
+ }
240
+ }
241
+ items.length = write;
242
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts";
2
2
  import type { Usage } from "../../shared/types.ts";
3
- import { checkModelScope, type ModelScopeConfig, type ModelScopeViolation, type ModelSource } from "./model-scope.ts";
3
+ import { filterFallbackCandidates, parseModelKey, recordModelFailure } from "./model-exclusions.ts";
4
+ import { checkModelScope, type ModelScopeCheckRule, type ModelScopeViolation, type ModelSource } from "./model-scope.ts";
4
5
 
5
6
  export type { AvailableModelInfo };
6
7
 
@@ -235,7 +236,7 @@ function resolveRequiredSubagentModelCandidate(
235
236
 
236
237
  export interface ResolveSubagentModelOverrideOptions {
237
238
  /** When set with `enforce: true`, out-of-scope models are rejected. */
238
- scope?: ModelScopeConfig;
239
+ scope?: ModelScopeCheckRule | ModelScopeCheckRule[];
239
240
  /** Origin of the requested model: explicit caller-supplied (hard error) vs inherited (warn). Defaults to `"inherited"`. */
240
241
  source?: ModelSource;
241
242
  /** Called for warn-severity violations instead of `console.warn`. */
@@ -246,6 +247,32 @@ function defaultScopeWarn(violation: ModelScopeViolation): void {
246
247
  console.warn(`[pi-subagents] ${violation.message}`);
247
248
  }
248
249
 
250
+ function configuredScopes(scope: ModelScopeCheckRule | ModelScopeCheckRule[] | undefined): ModelScopeCheckRule[] {
251
+ return scope ? (Array.isArray(scope) ? scope : [scope]) : [];
252
+ }
253
+
254
+ function throwForUnresolvedEnforcedInheritScope(scope: ModelScopeCheckRule | ModelScopeCheckRule[] | undefined, includeMixed = false): void {
255
+ const unresolvedInheritScope = configuredScopes(scope)
256
+ .find((entry) => entry.enforce === true && (includeMixed ? entry.allow?.includes(INHERIT_MODEL) : entry.allow?.length === 1 && entry.allow[0] === INHERIT_MODEL));
257
+ if (!unresolvedInheritScope) return;
258
+ const origin = unresolvedInheritScope.origin ?? "modelScope";
259
+ throw new Error(`Cannot enforce subagent model scope (${origin}): 'inherit' requires a current parent session model.`);
260
+ }
261
+
262
+ function enforceModelScopes(
263
+ model: string,
264
+ scope: ModelScopeCheckRule | ModelScopeCheckRule[] | undefined,
265
+ source: ModelSource,
266
+ onWarn: ((violation: ModelScopeViolation) => void) | undefined,
267
+ ): void {
268
+ const violations = configuredScopes(scope)
269
+ .map((entry) => checkModelScope(model, entry, source))
270
+ .filter((violation): violation is ModelScopeViolation => violation !== undefined);
271
+ const error = violations.find((violation) => violation.severity === "error");
272
+ if (error) throw new Error(error.message);
273
+ for (const violation of violations) (onWarn ?? defaultScopeWarn)(violation);
274
+ }
275
+
249
276
  /**
250
277
  * Resolve the `--model` override passed to a spawned subagent.
251
278
  *
@@ -273,19 +300,16 @@ export function resolveSubagentModelOverride(
273
300
  ): string | undefined {
274
301
  const trimmed = typeof requestedModel === "string" ? requestedModel.trim() : "";
275
302
  const explicit = trimmed && trimmed !== INHERIT_MODEL ? trimmed : undefined;
303
+ if (!parentModel) throwForUnresolvedEnforcedInheritScope(options?.scope, explicit === undefined || options?.source === "inherited");
276
304
  let resolved: string | undefined;
277
305
  if (explicit === undefined) {
278
306
  resolved = parentModel ? `${parentModel.provider}/${parentModel.id}` : undefined;
279
307
  } else {
280
308
  resolved = resolveRequiredSubagentModelCandidate(explicit, availableModels, preferredProvider);
281
309
  }
282
- if (resolved && options?.scope?.enforce) {
310
+ if (resolved && options?.scope) {
283
311
  const source: ModelSource = explicit === undefined ? "inherited" : (options.source ?? "inherited");
284
- const violation = checkModelScope(resolved, options.scope, source);
285
- if (violation) {
286
- if (violation.severity === "error") throw new Error(violation.message);
287
- (options.onWarn ?? defaultScopeWarn)(violation);
288
- }
312
+ enforceModelScopes(resolved, options.scope, source, options.onWarn);
289
313
  }
290
314
  return resolved;
291
315
  }
@@ -317,7 +341,7 @@ export function resolveEffectiveSubagentModel(
317
341
 
318
342
  export interface BuildModelCandidatesOptions {
319
343
  /** Fallback models warn by default and throw when strict scope enforcement is enabled. */
320
- scope?: ModelScopeConfig;
344
+ scope?: ModelScopeCheckRule | ModelScopeCheckRule[];
321
345
  onWarn?: (violation: ModelScopeViolation) => void;
322
346
  /** The primary model came from the running parent session, not configuration. */
323
347
  primaryModelFromParent?: boolean;
@@ -340,6 +364,7 @@ export function buildModelCandidates(
340
364
  preferredProvider?: string,
341
365
  options?: BuildModelCandidatesOptions,
342
366
  ): string[] {
367
+ if (!primaryModel) throwForUnresolvedEnforcedInheritScope(options?.scope, true);
343
368
  const seen = new Set<string>();
344
369
  const candidates: string[] = [];
345
370
  const rawCandidates = [primaryModel, ...(fallbackModels ?? [])];
@@ -357,17 +382,14 @@ export function buildModelCandidates(
357
382
  continue;
358
383
  }
359
384
  if (seen.has(normalized)) continue;
360
- if ((index > 0 || options?.scope?.strict === true) && options?.scope?.enforce) {
361
- const violation = checkModelScope(normalized, options.scope, "inherited");
362
- if (violation) {
363
- if (violation.severity === "error") throw new Error(violation.message);
364
- (options.onWarn ?? defaultScopeWarn)(violation);
365
- }
385
+ const scopes = configuredScopes(options?.scope);
386
+ if (index > 0 || scopes.some((scope) => scope.enforce === true && scope.strict === true)) {
387
+ enforceModelScopes(normalized, scopes, "inherited", options?.onWarn);
366
388
  }
367
389
  seen.add(normalized);
368
390
  candidates.push(normalized);
369
391
  }
370
- return candidates;
392
+ return filterFallbackCandidates(candidates);
371
393
  }
372
394
 
373
395
  const RETRYABLE_MODEL_FAILURE_PATTERNS = [
@@ -424,6 +446,40 @@ export function isRetryableModelFailure(error: string | undefined): boolean {
424
446
  return RETRYABLE_MODEL_FAILURE_PATTERNS.some((pattern) => pattern.test(error));
425
447
  }
426
448
 
449
+ export function recordRetryableModelFailure(model: string | undefined, error: string | undefined): void {
450
+ if (!model || !isRetryableModelFailure(error)) return;
451
+ const { provider, modelId } = parseModelKey(model);
452
+ recordModelFailure({ modelId, reason: error, ...(provider ? { provider } : {}) });
453
+ }
454
+
455
+ /**
456
+ * Context-overflow signals. These are deliberately NOT part of
457
+ * {@link RETRYABLE_MODEL_FAILURE_PATTERNS}: an overflow means the input was too
458
+ * large for the model's context window, so retrying the same input on another
459
+ * model (or the same model again) cannot succeed. Callers should treat overflow
460
+ * as a terminal, non-retryable failure and surface a clear "input too large"
461
+ * error instead of burning fallback attempts on a guaranteed failure.
462
+ */
463
+ const CONTEXT_OVERFLOW_PATTERNS = [
464
+ /context(?: length| window| limit)? (?:exceed|overflow|too long)/i,
465
+ /maximum context length/i,
466
+ /too many tokens/i,
467
+ /token limit/i,
468
+ /context_length_exceeded/i,
469
+ /length_required/i,
470
+ /maximum.*tokens/i,
471
+ /prompt.*too long/i,
472
+ /input.*too long/i,
473
+ /exceeded.*context/i,
474
+ /context.*overflow/i,
475
+ ];
476
+
477
+ export function isContextOverflow(error: string | undefined): boolean {
478
+ if (!error) return false;
479
+ if (TOOL_FAILURE_PREFIX.test(error.trim())) return false;
480
+ return CONTEXT_OVERFLOW_PATTERNS.some((pattern) => pattern.test(error));
481
+ }
482
+
427
483
  export function formatModelAttemptNote(attempt: ModelAttemptSummary, nextModel?: string): string {
428
484
  const failure = attempt.error?.trim() || `exit ${attempt.exitCode ?? 1}`;
429
485
  return nextModel