pi-subagents 0.52.0 → 0.53.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 (54) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +4 -0
  3. package/docs/configuration.md +11 -1
  4. package/docs/extension-api.md +3 -1
  5. package/docs/workflows.md +2 -0
  6. package/package.json +2 -1
  7. package/prompts/council.md +48 -0
  8. package/skills/council-mode/SKILL.md +230 -0
  9. package/skills/pi-subagents/SKILL.md +2 -0
  10. package/skills/pi-subagents/references/constraints-and-recipes.md +1 -0
  11. package/skills/pi-subagents/references/execution-controls.md +11 -0
  12. package/skills/pi-subagents/references/multi-lane-orchestration.md +39 -0
  13. package/src/agents/agent-management.ts +22 -3
  14. package/src/agents/agent-serializer.ts +2 -0
  15. package/src/agents/agents.ts +29 -13
  16. package/src/agents/builtin-names.ts +9 -0
  17. package/src/agents/runtime-agent-registry.ts +418 -0
  18. package/src/api/agents.ts +7 -0
  19. package/src/api/external-job-provider.ts +3 -2
  20. package/src/api/preflight.ts +1 -1
  21. package/src/extension/config.ts +3 -0
  22. package/src/extension/doctor.ts +1 -0
  23. package/src/extension/index.ts +17 -2
  24. package/src/extension/rpc.ts +41 -1
  25. package/src/extension/schemas.ts +7 -4
  26. package/src/extension/tool-description.ts +2 -2
  27. package/src/runs/background/async-execution.ts +2 -1
  28. package/src/runs/background/async-job-tracker.ts +4 -3
  29. package/src/runs/background/async-resume.ts +2 -1
  30. package/src/runs/background/async-status-snapshot.ts +14 -5
  31. package/src/runs/background/auto-drain.ts +1 -0
  32. package/src/runs/background/result-watcher.ts +8 -0
  33. package/src/runs/background/subagent-runner.ts +7 -4
  34. package/src/runs/background/subagent-wait.ts +9 -5
  35. package/src/runs/background/terminal-run-index.ts +15 -6
  36. package/src/runs/background/wait-tool.ts +1 -0
  37. package/src/runs/foreground/execution.ts +5 -1
  38. package/src/runs/foreground/subagent-executor.ts +182 -46
  39. package/src/runs/foreground/workflow-detach-reconcile.ts +83 -15
  40. package/src/runs/shared/acceptance.ts +44 -1
  41. package/src/runs/shared/model-exclusions.ts +242 -0
  42. package/src/runs/shared/model-fallback.ts +55 -2
  43. package/src/runs/shared/subagent-control.ts +25 -3
  44. package/src/shared/fork-context.ts +17 -1
  45. package/src/shared/model-info.ts +20 -0
  46. package/src/shared/settings.ts +2 -2
  47. package/src/shared/types.ts +35 -0
  48. package/src/slash/slash-commands.ts +20 -6
  49. package/src/slash/slash-live-state.ts +3 -3
  50. package/src/tui/fleet-status.ts +86 -1
  51. package/src/tui/fleet.ts +55 -2
  52. package/src/tui/render.ts +73 -3
  53. package/src/workflows/scripted-workflow.ts +100 -12
  54. 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,5 +1,6 @@
1
1
  import type { ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts";
2
2
  import type { Usage } from "../../shared/types.ts";
3
+ import { filterFallbackCandidates, parseModelKey, recordModelFailure } from "./model-exclusions.ts";
3
4
  import { checkModelScope, type ModelScopeConfig, type ModelScopeViolation, type ModelSource } from "./model-scope.ts";
4
5
 
5
6
  export type { AvailableModelInfo };
@@ -205,6 +206,21 @@ function resolveSubagentModelCandidate(
205
206
  return resolvedBase ? `${resolvedBase}${thinkingSuffix}` : undefined;
206
207
  }
207
208
 
209
+ function suggestAlternateProviderModel(
210
+ model: string,
211
+ availableModels: AvailableModelInfo[] | undefined,
212
+ ): string | undefined {
213
+ if (!availableModels || availableModels.length === 0) return undefined;
214
+ const { baseModel, thinkingSuffix } = splitThinkingSuffix(model);
215
+ const { queryProvider, queryIdRaw } = splitQualifiedModelQuery(baseModel, availableModels);
216
+ if (queryProvider === undefined) return undefined;
217
+ const suggestion = resolveBaseModelCandidate(queryIdRaw, availableModels);
218
+ if (!suggestion) return undefined;
219
+ const matched = availableModels.find((entry) => entry.fullId === suggestion);
220
+ if (!matched || normalizeModelSegment(matched.provider) === queryProvider) return undefined;
221
+ return `${suggestion}${thinkingSuffix}`;
222
+ }
223
+
208
224
  function resolveRequiredSubagentModelCandidate(
209
225
  model: string,
210
226
  availableModels: AvailableModelInfo[] | undefined,
@@ -212,7 +228,10 @@ function resolveRequiredSubagentModelCandidate(
212
228
  ): string {
213
229
  const resolved = resolveSubagentModelCandidate(model, availableModels, preferredProvider);
214
230
  if (resolved) return resolved;
215
- throw new Error(`Unknown subagent model '${model}' in the active Pi model registry.`);
231
+ const suggestion = suggestAlternateProviderModel(model, availableModels);
232
+ throw new Error(
233
+ `Unknown subagent model '${model}' in the active Pi model registry.${suggestion ? ` Did you mean '${suggestion}'?` : ""}`,
234
+ );
216
235
  }
217
236
 
218
237
  export interface ResolveSubagentModelOverrideOptions {
@@ -349,7 +368,7 @@ export function buildModelCandidates(
349
368
  seen.add(normalized);
350
369
  candidates.push(normalized);
351
370
  }
352
- return candidates;
371
+ return filterFallbackCandidates(candidates);
353
372
  }
354
373
 
355
374
  const RETRYABLE_MODEL_FAILURE_PATTERNS = [
@@ -406,6 +425,40 @@ export function isRetryableModelFailure(error: string | undefined): boolean {
406
425
  return RETRYABLE_MODEL_FAILURE_PATTERNS.some((pattern) => pattern.test(error));
407
426
  }
408
427
 
428
+ export function recordRetryableModelFailure(model: string | undefined, error: string | undefined): void {
429
+ if (!model || !isRetryableModelFailure(error)) return;
430
+ const { provider, modelId } = parseModelKey(model);
431
+ recordModelFailure({ modelId, reason: error, ...(provider ? { provider } : {}) });
432
+ }
433
+
434
+ /**
435
+ * Context-overflow signals. These are deliberately NOT part of
436
+ * {@link RETRYABLE_MODEL_FAILURE_PATTERNS}: an overflow means the input was too
437
+ * large for the model's context window, so retrying the same input on another
438
+ * model (or the same model again) cannot succeed. Callers should treat overflow
439
+ * as a terminal, non-retryable failure and surface a clear "input too large"
440
+ * error instead of burning fallback attempts on a guaranteed failure.
441
+ */
442
+ const CONTEXT_OVERFLOW_PATTERNS = [
443
+ /context(?: length| window| limit)? (?:exceed|overflow|too long)/i,
444
+ /maximum context length/i,
445
+ /too many tokens/i,
446
+ /token limit/i,
447
+ /context_length_exceeded/i,
448
+ /length_required/i,
449
+ /maximum.*tokens/i,
450
+ /prompt.*too long/i,
451
+ /input.*too long/i,
452
+ /exceeded.*context/i,
453
+ /context.*overflow/i,
454
+ ];
455
+
456
+ export function isContextOverflow(error: string | undefined): boolean {
457
+ if (!error) return false;
458
+ if (TOOL_FAILURE_PREFIX.test(error.trim())) return false;
459
+ return CONTEXT_OVERFLOW_PATTERNS.some((pattern) => pattern.test(error));
460
+ }
461
+
409
462
  export function formatModelAttemptNote(attempt: ModelAttemptSummary, nextModel?: string): string {
410
463
  const failure = attempt.error?.trim() || `exit ${attempt.exitCode ?? 1}`;
411
464
  return nextModel
@@ -17,6 +17,7 @@ const DEFAULT_NOTIFY_ON: ControlEventType[] = ["active_long_running", "needs_att
17
17
  export const DEFAULT_CONTROL_CONFIG: ResolvedControlConfig = {
18
18
  enabled: true,
19
19
  needsAttentionAfterMs: 60_000,
20
+ needsAttentionAfterMsIsExplicit: false,
20
21
  activeNoticeAfterMs: 240_000,
21
22
  failedToolAttemptsBeforeAttention: 3,
22
23
  notifyOn: DEFAULT_NOTIFY_ON,
@@ -42,9 +43,13 @@ export function resolveControlConfig(
42
43
  override?: ControlConfig,
43
44
  ): ResolvedControlConfig {
44
45
  const enabled = override?.enabled ?? globalConfig?.enabled ?? DEFAULT_CONTROL_CONFIG.enabled;
45
- const needsAttentionAfterMs = parsePositiveInt(override?.needsAttentionAfterMs)
46
- ?? parsePositiveInt(globalConfig?.needsAttentionAfterMs)
46
+ const overrideNeedsAttentionAfterMs = parsePositiveInt(override?.needsAttentionAfterMs);
47
+ const globalNeedsAttentionAfterMs = parsePositiveInt(globalConfig?.needsAttentionAfterMs);
48
+ const needsAttentionAfterMs = overrideNeedsAttentionAfterMs
49
+ ?? globalNeedsAttentionAfterMs
47
50
  ?? DEFAULT_CONTROL_CONFIG.needsAttentionAfterMs;
51
+ const needsAttentionAfterMsIsExplicit = overrideNeedsAttentionAfterMs !== undefined
52
+ || globalNeedsAttentionAfterMs !== undefined;
48
53
  const activeNoticeAfterMs = parsePositiveInt(override?.activeNoticeAfterMs)
49
54
  ?? parsePositiveInt(globalConfig?.activeNoticeAfterMs)
50
55
  ?? DEFAULT_CONTROL_CONFIG.activeNoticeAfterMs;
@@ -64,6 +69,7 @@ export function resolveControlConfig(
64
69
  return {
65
70
  enabled,
66
71
  needsAttentionAfterMs,
72
+ needsAttentionAfterMsIsExplicit,
67
73
  activeNoticeAfterMs,
68
74
  activeNoticeAfterTurns,
69
75
  activeNoticeAfterTokens,
@@ -73,18 +79,34 @@ export function resolveControlConfig(
73
79
  };
74
80
  }
75
81
 
82
+ function scaledNeedsAttentionAfterMs(config: ResolvedControlConfig, thinking?: string | false): number {
83
+ if (config.needsAttentionAfterMsIsExplicit !== false) return config.needsAttentionAfterMs;
84
+ switch (thinking) {
85
+ case "medium":
86
+ return config.needsAttentionAfterMs * 2;
87
+ case "high":
88
+ return config.needsAttentionAfterMs * 5;
89
+ case "xhigh":
90
+ case "max":
91
+ return config.needsAttentionAfterMs * 10;
92
+ default:
93
+ return config.needsAttentionAfterMs;
94
+ }
95
+ }
96
+
76
97
  export function deriveActivityState(input: {
77
98
  config: ResolvedControlConfig;
78
99
  startedAt: number;
79
100
  lastActivityAt?: number;
80
101
  currentTool?: string;
102
+ thinking?: string | false;
81
103
  now?: number;
82
104
  }): ActivityState | undefined {
83
105
  if (!input.config.enabled || input.currentTool) return undefined;
84
106
  const now = input.now ?? Date.now();
85
107
  const lastActivity = input.lastActivityAt ?? input.startedAt;
86
108
  const ageMs = Math.max(0, now - lastActivity);
87
- return ageMs > input.config.needsAttentionAfterMs ? "needs_attention" : undefined;
109
+ return ageMs > scaledNeedsAttentionAfterMs(input.config, input.thinking) ? "needs_attention" : undefined;
88
110
  }
89
111
 
90
112
  export function shouldEmitOpenToolAttention(input: {
@@ -208,7 +208,23 @@ export function createForkContextResolver(
208
208
  const openSession = options.openSession
209
209
  ?? sessionManager.openSession
210
210
  ?? ((file: string, dir?: string) => SessionManager.open(file, dir));
211
- const sessionDir = sessionManager.getSessionDir?.();
211
+ // Fork files must not land in the parent's top-level session directory.
212
+ // Pi's recent-session discovery (`pi -c` → findMostRecentSession) is
213
+ // non-recursive and picks the largest-mtime *.jsonl in that directory, so
214
+ // a still-running forked subagent — which keeps appending after the parent
215
+ // went idle — would hijack the next `pi -c` away from the conversation the
216
+ // user actually left. Nesting fork sessions in a per-parent directory keeps
217
+ // them invisible to that discovery; the `parentSession` header still
218
+ // records the tree relationship. The directory mirrors
219
+ // getSubagentSessionRoot() plus a "forks" level so fork files never sit
220
+ // loose next to run-N/ result directories. Derived from the file path
221
+ // rather than getSessionDir() so it also works when the manager cannot
222
+ // report its directory.
223
+ const sessionDir = path.join(
224
+ path.dirname(parentSessionFile),
225
+ path.basename(parentSessionFile, ".jsonl"),
226
+ "forks",
227
+ );
212
228
  const cachedResolutions = new Map<number, ForkContextResolution>();
213
229
 
214
230
  const resolveFork = (index = 0): ForkContextResolution => {
@@ -1,3 +1,5 @@
1
+ import type { ModelCost } from "@earendil-works/pi-ai";
2
+
1
3
  export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
2
4
  export type ThinkingLevel = typeof THINKING_LEVELS[number];
3
5
  export type ThinkingLevelMap = Partial<Record<ThinkingLevel, string | null>>;
@@ -9,6 +11,14 @@ export interface ModelInfo {
9
11
  api?: string;
10
12
  reasoning?: boolean;
11
13
  thinkingLevelMap?: ThinkingLevelMap;
14
+ /** Context window in tokens, when the host model registry reports one. */
15
+ contextWindow?: number;
16
+ /** Maximum output tokens, when the host model registry reports one. */
17
+ maxTokens?: number;
18
+ /** Input modalities reported by the registry, e.g. ["text", "image"]. */
19
+ input?: string[];
20
+ /** Per-token pricing from the registry (USD per 1M tokens). */
21
+ cost?: ModelCost;
12
22
  }
13
23
 
14
24
  interface RegistryModelLike {
@@ -17,6 +27,10 @@ interface RegistryModelLike {
17
27
  api?: string;
18
28
  reasoning?: boolean;
19
29
  thinkingLevelMap?: ThinkingLevelMap;
30
+ contextWindow?: number;
31
+ maxTokens?: number;
32
+ input?: string[];
33
+ cost?: ModelCost;
20
34
  }
21
35
 
22
36
  export function toModelInfo(model: RegistryModelLike): ModelInfo {
@@ -27,6 +41,12 @@ export function toModelInfo(model: RegistryModelLike): ModelInfo {
27
41
  api: model.api,
28
42
  reasoning: model.reasoning,
29
43
  thinkingLevelMap: model.thinkingLevelMap,
44
+ ...(typeof model.contextWindow === "number" && Number.isFinite(model.contextWindow) && model.contextWindow > 0 ? { contextWindow: model.contextWindow } : {}),
45
+ ...(typeof model.maxTokens === "number" && Number.isFinite(model.maxTokens) && model.maxTokens > 0 ? { maxTokens: model.maxTokens } : {}),
46
+ ...(Array.isArray(model.input) && model.input.length > 0 ? { input: [...model.input] } : {}),
47
+ ...(model.cost && Number.isFinite(model.cost.input) && Number.isFinite(model.cost.output)
48
+ ? { cost: { ...model.cost, ...(model.cost.tiers ? { tiers: model.cost.tiers.map((tier) => ({ ...tier })) } : {}) } }
49
+ : {}),
30
50
  };
31
51
  }
32
52