@ferris1225/pi-subagents 0.31.0 → 0.32.2

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.
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Pure view-model builder for the /subagents-inspect overlay.
3
+ *
4
+ * Joins three read-only sources into one snapshot:
5
+ * - monitor (live widget rows: status/usage/activity),
6
+ * - runtime.threads (thread state + control phase), and
7
+ * - inspectorStore (append-only trajectory + bounded transcript + retained
8
+ * snapshots for finished/parked threads whose monitor rows are gone).
9
+ *
10
+ * The overlay component renders this snapshot directly and never mutates
11
+ * runtime state; building it must therefore be side-effect free.
12
+ */
13
+
14
+ import { stripVTControlCharacters } from "node:util";
15
+ import { formatUsageCompact, monitor, type RunStatus } from "./monitor.ts";
16
+ import { emptyUsage, type UsageStats } from "./rpc-run.ts";
17
+ import type { SubagentRuntime, ThreadState } from "./runtime.ts";
18
+ import { inspectorStore, type TrajectoryEvent } from "./trajectory.ts";
19
+
20
+ export interface InspectorRunItem {
21
+ id: number;
22
+ agent: string;
23
+ label: string;
24
+ status: RunStatus;
25
+ /** Thread state text ("running", "parked", "completed", ...) or the monitor
26
+ * status label for threads without a control record (chain internals). */
27
+ stateText: string;
28
+ generation?: number;
29
+ elapsedMs?: number;
30
+ }
31
+
32
+ export interface InspectorToolEntry {
33
+ tool: string;
34
+ summary?: string;
35
+ isError?: boolean;
36
+ running?: boolean;
37
+ at: number;
38
+ }
39
+
40
+ export interface InspectorThreadView {
41
+ id: number;
42
+ agent: string;
43
+ label: string;
44
+ task: string;
45
+ status: RunStatus;
46
+ stateText: string;
47
+ /** Control phase of the current attempt (queued/starting/running/...). */
48
+ phase?: string;
49
+ generation?: number;
50
+ elapsedMs?: number;
51
+ startedAt?: number;
52
+ endedAt?: number;
53
+ model?: string;
54
+ /** Ordered candidate refs from the dispatch record (primary first). */
55
+ modelChain: string[];
56
+ modelFallbackFrom?: string;
57
+ thinking?: string;
58
+ isolation?: "shared" | "worktree";
59
+ integrationStatus?: string;
60
+ integrationApplied?: boolean;
61
+ originalCwd?: string;
62
+ isolationCwd?: string;
63
+ integrationWorktreePath?: string;
64
+ integrationPatchPath?: string;
65
+ integrationError?: string;
66
+ forkedFromRunId?: number;
67
+ forkChildRunIds: number[];
68
+ usage: UsageStats;
69
+ toolCount: number;
70
+ activity?: string;
71
+ currentTool?: string;
72
+ tools: InspectorToolEntry[];
73
+ trajectory: readonly TrajectoryEvent[];
74
+ trajectoryTotal: number;
75
+ transcript: { text: string; textTruncated: boolean; thinking: string; thinkingTruncated: boolean };
76
+ }
77
+
78
+ export interface InspectorSnapshot {
79
+ now: number;
80
+ items: InspectorRunItem[];
81
+ detail?: InspectorThreadView;
82
+ }
83
+
84
+ const TOOL_LIMIT = 6;
85
+ const TRAJECTORY_LIMIT = 10;
86
+
87
+ function monitorRunsSafe(): ReturnType<typeof monitor.getRuns> {
88
+ try {
89
+ return monitor.getRuns();
90
+ } catch {
91
+ return [];
92
+ }
93
+ }
94
+
95
+ function threadStatusToRunStatus(state: ThreadState): RunStatus {
96
+ switch (state) {
97
+ case "queued":
98
+ case "resuming":
99
+ return "queued";
100
+ case "running":
101
+ return "running";
102
+ case "steering":
103
+ return "steering";
104
+ case "interrupting":
105
+ return "interrupting";
106
+ case "parked":
107
+ return "parked";
108
+ case "completed":
109
+ return "done";
110
+ case "failed":
111
+ case "stopped":
112
+ return "failed";
113
+ }
114
+ }
115
+
116
+ export function buildInspectorSnapshot(options: {
117
+ runtime: Pick<SubagentRuntime, "threads">;
118
+ selectedId?: number;
119
+ now?: number;
120
+ toolLimit?: number;
121
+ trajectoryLimit?: number;
122
+ }): InspectorSnapshot {
123
+ const now = options.now ?? Date.now();
124
+ const toolLimit = options.toolLimit ?? TOOL_LIMIT;
125
+ const trajectoryLimit = options.trajectoryLimit ?? TRAJECTORY_LIMIT;
126
+ const monitorRuns = monitorRunsSafe();
127
+
128
+ // Union of the three sources keyed by id.
129
+ const ids = new Set<number>();
130
+ for (const id of options.runtime.threads.keys()) ids.add(id);
131
+ for (const state of inspectorStore.all()) ids.add(state.runId);
132
+ for (const run of monitorRuns) ids.add(run.id);
133
+
134
+ const items: InspectorRunItem[] = [];
135
+ let detail: InspectorThreadView | undefined;
136
+
137
+ for (const id of [...ids].sort((a, b) => a - b)) {
138
+ const monitorRun = monitorRuns.find((r) => r.id === id);
139
+ const thread = options.runtime.threads.get(id);
140
+ const inspect = inspectorStore.find(id);
141
+
142
+ const latestSettled = [...(inspect?.trajectory.getGenerationEvents() ?? [])]
143
+ .reverse()
144
+ .find((event): event is Extract<TrajectoryEvent, { kind: "settled" }> => event.kind === "settled");
145
+ const trajectoryStatus = latestSettled
146
+ ? latestSettled.status === "done" ? "done" : "failed"
147
+ : undefined;
148
+ const agent = monitorRun?.agent ?? thread?.agentName ?? inspect?.agent ?? "?";
149
+ const label = monitorRun?.label ?? inspect?.label ?? "";
150
+ const task = monitorRun?.task ?? thread?.task ?? inspect?.task ?? "";
151
+ const status: RunStatus = monitorRun?.status ?? (thread
152
+ ? threadStatusToRunStatus(thread.state)
153
+ : trajectoryStatus ?? statusFromText(inspect?.status));
154
+ const stateText = thread?.state ?? monitorRun?.status ?? latestSettled?.status ?? inspect?.status ?? "queued";
155
+ const startedAt = monitorRun?.startedAt ?? inspect?.startedAt;
156
+ const endedAt = monitorRun?.endedAt ?? inspect?.endedAt ?? inspect?.trajectory.summary().endedAt;
157
+ const elapsedMs = startedAt === undefined ? undefined : Math.max(0, (endedAt ?? now) - startedAt);
158
+
159
+ items.push({ id, agent, label, status, stateText, generation: thread?.generation ?? inspect?.generation, elapsedMs });
160
+
161
+ if (options.selectedId === id) {
162
+ detail = buildDetail({
163
+ id,
164
+ agent,
165
+ label,
166
+ task,
167
+ status,
168
+ stateText,
169
+ startedAt,
170
+ endedAt,
171
+ elapsedMs,
172
+ monitorRun,
173
+ thread,
174
+ inspect,
175
+ toolLimit,
176
+ trajectoryLimit,
177
+ });
178
+ }
179
+ }
180
+
181
+ return { now, items, detail };
182
+ }
183
+
184
+ function statusFromText(text: string | undefined): RunStatus {
185
+ switch (text) {
186
+ case "done":
187
+ case "completed":
188
+ return "done";
189
+ case "failed":
190
+ case "stopped":
191
+ return "failed";
192
+ case "running":
193
+ return "running";
194
+ case "steering":
195
+ return "steering";
196
+ case "interrupting":
197
+ return "interrupting";
198
+ case "parked":
199
+ return "parked";
200
+ default:
201
+ return "queued";
202
+ }
203
+ }
204
+
205
+ function buildDetail(input: {
206
+ id: number;
207
+ agent: string;
208
+ label: string;
209
+ task: string;
210
+ status: RunStatus;
211
+ stateText: string;
212
+ startedAt?: number;
213
+ endedAt?: number;
214
+ elapsedMs?: number;
215
+ monitorRun?: ReturnType<typeof monitor.getRuns>[number];
216
+ thread?: {
217
+ state: ThreadState;
218
+ generation: number;
219
+ control: { getPhase(): string };
220
+ isolation: "shared" | "worktree";
221
+ cwd: string;
222
+ executionCwd: string;
223
+ worktree?: { worktreePath: string; patchPath: string; state: string };
224
+ forkedFromRunId?: number;
225
+ forkChildRunIds: number[];
226
+ };
227
+ inspect?: ReturnType<typeof inspectorStore.find>;
228
+ toolLimit: number;
229
+ trajectoryLimit: number;
230
+ }): InspectorThreadView {
231
+ const { monitorRun, thread, inspect } = input;
232
+ const genEvents = inspect?.trajectory.getGenerationEvents() ?? [];
233
+ const allEvents = inspect?.trajectory.getEvents() ?? [];
234
+
235
+ // Model chain: primary first, then the recorded pool fallbacks. The actual
236
+ // model currently running is the trajectory summary's latest, or the live
237
+ // monitor model. modelFallbackFrom marks a pool advancement.
238
+ const dispatchEvent = [...genEvents].reverse().find((e): e is Extract<TrajectoryEvent, { kind: "dispatch" }> => e.kind === "dispatch");
239
+ const chain: string[] = [];
240
+ if (dispatchEvent?.model) chain.push(dispatchEvent.model);
241
+ for (const ref of dispatchEvent?.pool ?? []) if (ref && !chain.includes(ref)) chain.push(ref);
242
+ const model = monitorRun?.model ?? inspect?.trajectory.summary().model ?? inspect?.model;
243
+ const fallbackFrom = monitorRun?.modelFallbackFrom ?? inspect?.trajectory.summary().modelFallbackFrom;
244
+ if (model && !chain.includes(model)) chain.push(model);
245
+
246
+ // Recent tool entries from the current-generation trajectory.
247
+ const tools: InspectorToolEntry[] = [];
248
+ for (let i = genEvents.length - 1; i >= 0 && tools.length < input.toolLimit; i--) {
249
+ const e = genEvents[i];
250
+ if (e.kind === "tool_start") {
251
+ // Correlate parallel calls by Pi's stable toolCallId. Name matching is
252
+ // retained only for legacy events that predate id capture.
253
+ const ended = genEvents.slice(i + 1).find((later) =>
254
+ later.kind === "tool_end" &&
255
+ (e.toolCallId
256
+ ? later.toolCallId === e.toolCallId
257
+ : later.toolCallId === undefined && later.tool === e.tool),
258
+ );
259
+ tools.unshift({
260
+ tool: e.tool,
261
+ summary: e.summary || undefined,
262
+ isError: ended && ended.kind === "tool_end" ? ended.isError : undefined,
263
+ running: ended === undefined,
264
+ at: e.at,
265
+ });
266
+ }
267
+ }
268
+
269
+ const trajectory = genEvents.slice(-input.trajectoryLimit);
270
+ const usage = monitorRun?.usage ?? inspect?.runInfo?.usage ?? emptyUsage();
271
+ const toolCount = monitorRun?.toolCount ?? inspect?.runInfo?.toolCount ?? inspect?.trajectory.summary().toolCount ?? 0;
272
+ const trajectorySummary = inspect?.trajectory.summary();
273
+ const lastWorktreeEvent = [...allEvents]
274
+ .reverse()
275
+ .find((event): event is Extract<TrajectoryEvent, { kind: "worktree" }> => event.kind === "worktree");
276
+ const isolation = thread?.isolation ?? monitorRun?.isolation ?? trajectorySummary?.isolation;
277
+
278
+ return {
279
+ id: input.id,
280
+ agent: input.agent,
281
+ label: input.label,
282
+ task: input.task,
283
+ status: input.status,
284
+ stateText: input.stateText,
285
+ phase: thread?.control.getPhase(),
286
+ generation: thread?.generation ?? (genEvents.length > 0 ? inspect?.generation : undefined),
287
+ elapsedMs: input.elapsedMs,
288
+ startedAt: input.startedAt,
289
+ endedAt: input.endedAt,
290
+ model: model ?? undefined,
291
+ modelChain: chain,
292
+ modelFallbackFrom: fallbackFrom,
293
+ thinking: monitorRun?.thinking ?? trajectorySummary?.thinking ?? inspect?.thinking,
294
+ isolation,
295
+ integrationStatus: monitorRun?.integrationStatus ?? trajectorySummary?.integrationStatus ?? thread?.worktree?.state,
296
+ integrationApplied: lastWorktreeEvent?.integrated,
297
+ originalCwd: thread?.cwd ?? trajectorySummary?.originalCwd,
298
+ isolationCwd: thread?.executionCwd ?? trajectorySummary?.isolationCwd,
299
+ integrationWorktreePath: lastWorktreeEvent?.worktreePath ?? (thread?.worktree?.state === "retained" ? thread.worktree.worktreePath : undefined),
300
+ integrationPatchPath: lastWorktreeEvent?.patchPath ?? (thread?.worktree?.state === "retained" ? thread.worktree.patchPath : undefined),
301
+ integrationError: lastWorktreeEvent?.error,
302
+ forkedFromRunId: thread?.forkedFromRunId ?? trajectorySummary?.forkedFromRunId,
303
+ forkChildRunIds: [...(thread?.forkChildRunIds ?? trajectorySummary?.forkChildRunIds ?? [])],
304
+ usage,
305
+ toolCount,
306
+ activity: monitorRun?.activity ?? inspect?.runInfo?.activity ?? inspect?.trajectory.summary().activity,
307
+ currentTool: monitorRun?.currentTool ?? inspect?.runInfo?.currentTool ?? inspect?.trajectory.summary().currentTool,
308
+ tools,
309
+ trajectory,
310
+ trajectoryTotal: allEvents.length,
311
+ transcript: inspect?.transcript.snapshot() ?? { text: "", textTruncated: false, thinking: "", thinkingTruncated: false },
312
+ };
313
+ }
314
+
315
+ /** One-line event text for the trajectory pane (plain, pre-theme). */
316
+ export function formatTrajectoryEvent(event: TrajectoryEvent): string {
317
+ return stripVTControlCharacters(formatTrajectoryEventUnsafe(event));
318
+ }
319
+
320
+ function formatTrajectoryEventUnsafe(event: TrajectoryEvent): string {
321
+ switch (event.kind) {
322
+ case "dispatch":
323
+ return `dispatch ${event.agent}${event.resumed ? " (resumed)" : ""}${event.model ? ` · ${event.model}` : ""}`;
324
+ case "status":
325
+ return `status: ${event.status}`;
326
+ case "candidate":
327
+ return event.fallbackFrom ? `model: ${event.model ?? "?"} (pool fallback from ${event.fallbackFrom})` : `model: ${event.model ?? "?"}`;
328
+ case "retry":
329
+ return `retry${event.reason ? `: ${event.reason}` : ""}`;
330
+ case "steer":
331
+ return `steer: ${compactOne(event.instruction, 60)}`;
332
+ case "retarget":
333
+ return `retarget: ${compactOne(event.objective, 60)}`;
334
+ case "park":
335
+ return "parked at checkpoint";
336
+ case "resume":
337
+ return event.objective ? `resume: ${compactOne(event.objective, 60)}` : "resume from retained context";
338
+ case "fork":
339
+ return event.runId === event.sourceRunId
340
+ ? `forked child #${event.childRunId}${event.objective ? `: ${compactOne(event.objective, 48)}` : ""}`
341
+ : `forked from #${event.sourceRunId}${event.objective ? `: ${compactOne(event.objective, 48)}` : ""}`;
342
+ case "stop":
343
+ return event.reason ? `stop: ${compactOne(event.reason, 60)}` : "stopped";
344
+ case "worktree":
345
+ return event.status === "created"
346
+ ? `worktree created${event.isolationCwd ? ` · ${compactOne(event.isolationCwd, 48)}` : ""}`
347
+ : event.status === "retained" && event.integrated
348
+ ? `worktree cleanup failed after apply${event.error ? `: ${compactOne(event.error, 48)}` : ""}`
349
+ : `worktree ${event.status}${event.error ? `: ${compactOne(event.error, 48)}` : ""}`;
350
+ case "settled":
351
+ return `settled: ${event.status}${event.model ? ` · ${event.model}` : ""}${event.integrationStatus ? ` · worktree ${event.integrationStatus}` : ""}`;
352
+ case "tool_start":
353
+ return `→ ${event.tool}${event.summary ? ` ${compactOne(event.summary, 48)}` : ""}`;
354
+ case "tool_end":
355
+ return `${e2(event)}${event.tool}`;
356
+ case "usage":
357
+ return `usage: ${formatUsageCompact(event.usage) || "—"}`;
358
+ }
359
+ }
360
+
361
+ function e2(event: Extract<TrajectoryEvent, { kind: "tool_end" }>): string {
362
+ return event.isError ? "✗ " : "✓ ";
363
+ }
364
+
365
+ function compactOne(text: string, max: number): string {
366
+ const oneLine = stripVTControlCharacters(text).replace(/\s+/g, " ").trim();
367
+ const chars = [...oneLine];
368
+ return chars.length > max ? `${chars.slice(0, max - 1).join("")}…` : oneLine;
369
+ }
package/src/models.ts CHANGED
@@ -1,82 +1,224 @@
1
- /**
2
- * Model availability helpers shared by setup and sub-agent execution.
1
+ /*
2
+ * Model-pool resolution and setup-picker helpers.
3
3
  *
4
- * A configured override can outlive a provider login, a scoped-model change, or
5
- * a model rename. Keep the main session usable by replacing such overrides with
6
- * the model currently selected in the main window (or the first available model)
7
- * and let callers persist the repaired mapping.
4
+ * Runtime pools deliberately do not filter configured references by current
5
+ * availability: a stale primary/backup is attempted and normal provider/model
6
+ * failure handling advances to the next candidate. Setup uses the same catalog
7
+ * only for honest availability labels; it never rewrites persisted choices.
8
8
  */
9
9
 
10
+ import type { Api, Model } from "@earendil-works/pi-ai";
10
11
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
12
 
12
- export type ModelContext = Pick<ExtensionContext, "model" | "scopedModels" | "modelRegistry">;
13
+ export type ModelContext = Pick<ExtensionContext, "model" | "modelRegistry"> &
14
+ Partial<Pick<ExtensionContext, "scopedModels">>;
15
+
16
+ export const CURRENT_MAIN_MODEL = "__current_main_model__";
17
+
18
+ export type ModelPoolSlot = "primary" | "backup";
19
+ export type ModelPickerSlot = ModelPoolSlot | "vision";
20
+
21
+ export interface ModelPickerItem {
22
+ value: string;
23
+ label: string;
24
+ description?: string;
25
+ /** Visible for diagnosis/search, but cannot be selected. */
26
+ disabled?: boolean;
27
+ }
28
+
29
+ export type ModelListEntry = Pick<
30
+ Model<Api>,
31
+ "provider" | "id" | "name" | "input" | "reasoning"
32
+ >;
33
+
34
+ export interface ResolvedAgentModelPool {
35
+ /** Effective first candidate. Undefined means let pi use its normal default. */
36
+ primaryRef?: string;
37
+ /** Ordered candidates after the primary, already deduplicated. */
38
+ fallbackModelRefs: string[];
39
+ /** All known references in runtime order, useful for tests/inspection. */
40
+ candidateRefs: string[];
41
+ }
42
+
43
+ export interface AgentModelPoolInput {
44
+ primaryRef?: string;
45
+ backupRef?: string;
46
+ mainRef?: string;
47
+ declaredDefaultRef?: string;
48
+ }
13
49
 
14
- export interface ModelOverrideRepair {
50
+ export interface AgentModelPoolMaps {
15
51
  agentModels: Record<string, string>;
16
- changed: boolean;
17
- replaced: number;
18
- removed: number;
19
- fallbackRef?: string;
52
+ agentBackupModels: Record<string, string>;
53
+ }
54
+
55
+ export interface AgentModelPoolRow {
56
+ name: string;
57
+ primary: string;
58
+ backup: string;
59
+ }
60
+
61
+ function cleanModelRef(ref: string | undefined): string | undefined {
62
+ const trimmed = ref?.trim();
63
+ return trimmed || undefined;
20
64
  }
21
65
 
22
66
  export function modelRef(model: { provider: string; id: string }): string {
23
67
  return `${model.provider}/${model.id}`;
24
68
  }
25
69
 
70
+ export function currentModelRef(ctx: Pick<ModelContext, "model">): string | undefined {
71
+ return ctx.model ? modelRef(ctx.model) : undefined;
72
+ }
73
+
26
74
  /**
27
- * Return model references usable by the current main window, with its current
28
- * model first so it is the deterministic fallback for stale configuration.
75
+ * Models usable by this main window. Scoped models replace the full available
76
+ * registry, matching pi's built-in model picker semantics.
29
77
  */
30
78
  export function availableModelRefs(ctx: ModelContext): string[] {
31
- const scoped = ctx.scopedModels.length > 0 ? ctx.scopedModels.map((entry) => entry.model) : undefined;
79
+ // scopedModels was added after the declared Pi 0.80.6 minimum. Treat a
80
+ // missing field exactly like an empty scope and use the registry fallback.
81
+ const scopedModels = ctx.scopedModels ?? [];
82
+ const scoped = scopedModels.length > 0 ? scopedModels.map((entry) => entry.model) : undefined;
32
83
  const models = scoped ?? ctx.modelRegistry.getAvailable();
33
84
  const refs = [...new Set(models.map(modelRef))];
34
- const currentRef = ctx.model ? modelRef(ctx.model) : undefined;
85
+ const currentRef = currentModelRef(ctx);
35
86
  if (!currentRef) return refs;
36
87
  return [currentRef, ...refs.filter((ref) => ref !== currentRef)];
37
88
  }
38
89
 
39
90
  /**
40
- * Resolve the model for a `vision: true` dispatch. The configured vision model
41
- * wins when it is usable by the current session; otherwise the task falls back
42
- * to the main window's current model (the documented behavior when the vision
43
- * model is unset). Returns undefined only when neither exists — callers then
44
- * keep the agent's own model as the last resort.
91
+ * Resolve one agent's ordered runtime pool:
92
+ *
93
+ * configured primary -> configured backup -> current main-window model
94
+ *
95
+ * Without a primary override, the current main model remains the primary; an
96
+ * agent-declared default is used only when no main model exists. Equal refs are
97
+ * removed without consulting availability, so stale refs stay in the chain and
98
+ * fail normally at runtime instead of being silently repaired.
45
99
  */
46
- export function resolveVisionModelRef(ctx: ModelContext, visionModel?: string): string | undefined {
47
- const configured = visionModel?.trim();
48
- if (configured && availableModelRefs(ctx).includes(configured)) return configured;
49
- return ctx.model ? modelRef(ctx.model) : undefined;
100
+ export function resolveAgentModelPool(input: AgentModelPoolInput): ResolvedAgentModelPool {
101
+ const mainRef = cleanModelRef(input.mainRef);
102
+ const primaryRef = cleanModelRef(input.primaryRef) ?? mainRef ?? cleanModelRef(input.declaredDefaultRef);
103
+ const ordered = [primaryRef, cleanModelRef(input.backupRef), mainRef];
104
+ const seen = new Set<string>();
105
+ const candidateRefs: string[] = [];
106
+ for (const ref of ordered) {
107
+ if (!ref || seen.has(ref)) continue;
108
+ seen.add(ref);
109
+ candidateRefs.push(ref);
110
+ }
111
+ const fallbackModelRefs = candidateRefs.filter((ref) => ref !== primaryRef);
112
+ return { primaryRef, fallbackModelRefs, candidateRefs };
50
113
  }
51
114
 
52
- /** Replace unavailable persisted overrides with a model usable by the main session. */
53
- export function repairUnavailableModelOverrides(
54
- ctx: ModelContext,
55
- agentModels: Record<string, string>,
56
- ): ModelOverrideRepair {
57
- const refs = availableModelRefs(ctx);
58
- const available = new Set(refs);
59
- const fallbackRef = refs[0];
60
- const repaired: Record<string, string> = {};
61
- let changed = false;
62
- let replaced = 0;
63
- let removed = 0;
64
-
65
- for (const [name, configuredRef] of Object.entries(agentModels)) {
66
- const ref = configuredRef.trim();
67
- if (available.has(ref)) {
68
- repaired[name] = ref;
69
- continue;
115
+ function modelCapabilities(model: ModelListEntry): string {
116
+ const capabilities = [model.input.includes("image") ? "vision" : "text-only"];
117
+ if (model.reasoning) capabilities.push("reasoning");
118
+ return capabilities.join(" + ");
119
+ }
120
+
121
+ /** Build the single searchable model list shared by primary/backup/vision picks. */
122
+ export function buildModelPickerItems(options: {
123
+ models: readonly ModelListEntry[];
124
+ availableRefs: readonly string[];
125
+ slot: ModelPickerSlot;
126
+ configuredRef?: string;
127
+ mainRef?: string;
128
+ }): ModelPickerItem[] {
129
+ const configuredRef = cleanModelRef(options.configuredRef);
130
+ const mainRef = cleanModelRef(options.mainRef);
131
+ const available = new Set(options.availableRefs.map((ref) => ref.trim()));
132
+ const byRef = new Map<string, ModelListEntry>();
133
+ for (const model of options.models) {
134
+ const ref = modelRef(model);
135
+ if (!byRef.has(ref)) byRef.set(ref, model);
136
+ }
137
+
138
+ const refs = [...byRef.keys()]
139
+ .filter((ref) =>
140
+ options.slot !== "vision" ||
141
+ byRef.get(ref)?.input.includes("image") === true ||
142
+ ref === configuredRef,
143
+ )
144
+ .sort((left, right) => {
145
+ const leftRank = left === configuredRef ? 0 : left === mainRef ? 1 : 2;
146
+ const rightRank = right === configuredRef ? 0 : right === mainRef ? 1 : 2;
147
+ return leftRank - rightRank || left.localeCompare(right);
148
+ });
149
+ if (configuredRef && !byRef.has(configuredRef)) refs.unshift(configuredRef);
150
+
151
+ const dynamic = options.slot === "backup"
152
+ ? {
153
+ value: CURRENT_MAIN_MODEL,
154
+ label: "Current main model (default)",
155
+ description: "Clear configured backup; use the main-window model dynamically",
70
156
  }
157
+ : {
158
+ value: CURRENT_MAIN_MODEL,
159
+ label: "Current main model (dynamic)",
160
+ description: options.slot === "vision"
161
+ ? "Clear vision override; use the main-window model for vision tasks"
162
+ : "Clear primary override; use the main-window model dynamically",
163
+ };
71
164
 
72
- changed = true;
73
- if (fallbackRef) {
74
- repaired[name] = fallbackRef;
75
- replaced++;
76
- } else {
77
- removed++;
165
+ const items: ModelPickerItem[] = [dynamic];
166
+ for (const ref of refs) {
167
+ const model = byRef.get(ref);
168
+ const tags = [available.has(ref) ? "available" : "unavailable"];
169
+ if (ref === configuredRef) tags.push("configured");
170
+ if (ref === mainRef) tags.push("current main");
171
+ if (!model) {
172
+ const compatibility = options.slot === "vision"
173
+ ? "incompatible with vision (capability unknown)"
174
+ : undefined;
175
+ items.push({
176
+ value: ref,
177
+ label: ref,
178
+ description: [...tags, compatibility, "saved model reference"].filter(Boolean).join(" · "),
179
+ ...(options.slot === "vision" ? { disabled: true } : {}),
180
+ });
181
+ continue;
78
182
  }
183
+ const name = model.name.trim() && model.name !== model.id ? model.name.trim() : undefined;
184
+ const compatibility = options.slot === "vision" && !model.input.includes("image")
185
+ ? "incompatible with vision"
186
+ : undefined;
187
+ items.push({
188
+ value: ref,
189
+ label: ref,
190
+ description: [name, modelCapabilities(model), compatibility, ...tags].filter(Boolean).join(" · "),
191
+ ...(compatibility ? { disabled: true } : {}),
192
+ });
79
193
  }
194
+ return items;
195
+ }
196
+
197
+ /** Pure pool update: the dynamic/default choice removes the persisted override. */
198
+ export function applyModelPoolChoice(
199
+ current: AgentModelPoolMaps,
200
+ agentName: string,
201
+ slot: ModelPoolSlot,
202
+ choice: string,
203
+ ): AgentModelPoolMaps {
204
+ const next: AgentModelPoolMaps = {
205
+ agentModels: { ...current.agentModels },
206
+ agentBackupModels: { ...current.agentBackupModels },
207
+ };
208
+ const target = slot === "primary" ? next.agentModels : next.agentBackupModels;
209
+ if (choice === CURRENT_MAIN_MODEL) delete target[agentName];
210
+ else target[agentName] = choice.trim();
211
+ return next;
212
+ }
80
213
 
81
- return { agentModels: repaired, changed, replaced, removed, fallbackRef };
214
+ /** Pure rows used by the overview component and focused helper tests. */
215
+ export function buildAgentModelPoolRows(
216
+ agentNames: readonly string[],
217
+ pools: AgentModelPoolMaps,
218
+ ): AgentModelPoolRow[] {
219
+ return agentNames.map((name) => ({
220
+ name,
221
+ primary: pools.agentModels[name] ?? "Main (dynamic)",
222
+ backup: pools.agentBackupModels[name] ?? "Main (default)",
223
+ }));
82
224
  }