@ferris1225/pi-subagents 1.0.0 → 2.0.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.
package/src/fixloop.ts CHANGED
@@ -86,22 +86,6 @@ export function chainKeyFragments(result: SingleResult): string[] {
86
86
  return extractKeyFragments(getResultOutput(result)).slice(0, CHAIN_SUMMARY_FRAGMENTS_MAX);
87
87
  }
88
88
 
89
- /**
90
- * Compact one-line outcome for a finished chain run, retained so
91
- * each round reads as what it did: a reviewer reports its verdict plus the
92
- * key fragments of what it found ("fail · src/index.ts · render()"), a worker
93
- * the fragments of what it changed. Failed runs and runs with nothing
94
- * distinctive get no summary.
95
- */
96
- export function summarizeChainResult(result: SingleResult): string | undefined {
97
- if (isFailedResult(result)) return undefined;
98
- const verdict = result.agent === "reviewer" ? reviewVerdict(getResultOutput(result)) : undefined;
99
- if (verdict === "pass") return "pass";
100
- const fragments = chainKeyFragments(result);
101
- if (verdict === "fail") return fragments.length > 0 ? `fail · ${fragments.join(" · ")}` : "fail";
102
- return fragments.length > 0 ? fragments.join(" · ") : undefined;
103
- }
104
-
105
89
  /**
106
90
  * Condensed, readable summary of a completed auto-fix chain: one line per step
107
91
  * (run id, role, verdict / what changed) plus aggregate usage. Full per-step
package/src/format.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { AgentConfig } from "./agents.ts";
8
8
  import { formatTaskSummary } from "./monitor.ts";
9
+ import { emptyUsage } from "./rpc-run.ts";
9
10
  import {
10
11
  getResultOutput,
11
12
  isFailedResult,
@@ -15,14 +16,9 @@ import {
15
16
  type UsageStats,
16
17
  } from "./spawn.ts";
17
18
 
18
- export function emptyUsage(): UsageStats {
19
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
20
- }
21
-
22
19
  export function queuedResult(agent: AgentConfig, task: string, thinking?: string): SingleResult {
23
20
  return {
24
21
  agent: agent.name,
25
- agentSource: agent.source,
26
22
  task,
27
23
  exitCode: -1,
28
24
  messages: [],
@@ -36,7 +32,6 @@ export function queuedResult(agent: AgentConfig, task: string, thinking?: string
36
32
  export function failedStartResult(agentName: string, task: string, errorMessage: string): SingleResult {
37
33
  return {
38
34
  agent: agentName,
39
- agentSource: "unknown",
40
35
  task,
41
36
  exitCode: 1,
42
37
  messages: [],
@@ -61,7 +56,7 @@ export function dispatchFailedResult(agent: AgentConfig, task: string, error: un
61
56
  };
62
57
  }
63
58
 
64
- export function formatTokens(count: number): string {
59
+ function formatTokens(count: number): string {
65
60
  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
66
61
  if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
67
62
  return String(count);
@@ -78,33 +73,40 @@ export function formatUsage(usage: UsageStats): string {
78
73
  return parts.join(" ");
79
74
  }
80
75
 
81
- export function formatCompletionBlock(result: SingleResult, maxResultLines: number, cwd?: string): string {
76
+ export interface CompletionFormatOptions {
77
+ /** Include individual failed-tool errors. Reserved for explicit status lookup. */
78
+ failedToolDetails?: boolean;
79
+ }
80
+
81
+ export function formatCompletionBlock(
82
+ result: SingleResult,
83
+ maxResultLines: number,
84
+ cwd?: string,
85
+ options: CompletionFormatOptions = {},
86
+ ): string {
82
87
  const failed = isFailedResult(result);
83
88
  const failedTools = result.failedTools ?? [];
84
89
  const status = failed
85
90
  ? "failed"
86
- : failedTools.length > 0
91
+ : options.failedToolDetails && failedTools.length > 0
87
92
  ? `completed with ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}`
88
93
  : "completed";
89
94
  const usage = formatUsage(result.usage);
90
95
  const output = getResultOutput(result);
91
96
  const { text, truncated } = truncateResultOutput(output, maxResultLines);
92
97
  const fallbackNote = result.modelFallbackFrom
93
- ? ` (model pool fallback: primary ${result.modelFallbackFrom} → final ${result.model ?? "dynamic default"})`
98
+ ? ` (selected model ${result.modelFallbackFrom} failed main ${result.model ?? "dynamic default"})`
94
99
  : "";
95
100
  const startupRetryNote = result.startupRetries
96
101
  ? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} — concurrent pi startup race)`
97
102
  : "";
98
- const modelRetryNote = result.modelRetries
99
- ? ` (recovered after ${result.modelRetries} same-model retr${result.modelRetries === 1 ? "y" : "ies"} on a transient provider error)`
100
- : "";
101
103
  const relations = [
102
104
  result.forkedFromRunId !== undefined ? `forked from #${result.forkedFromRunId}` : undefined,
103
105
  (result.forkChildRunIds?.length ?? 0) > 0 ? `fork children ${result.forkChildRunIds!.map((id) => `#${id}`).join(", ")}` : undefined,
104
106
  ].filter((value): value is string => Boolean(value));
105
107
  const relationNote = relations.length > 0 ? ` · ${relations.join(" · ")}` : "";
106
108
  const runNote = result.runId !== undefined ? ` · run #${result.runId}` : "";
107
- const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${modelRetryNote}${runNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, ""];
109
+ const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${runNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, ""];
108
110
  if (result.isolation === "worktree") {
109
111
  const isolation =
110
112
  result.integrationStatus === "integrated"
@@ -125,23 +127,22 @@ export function formatCompletionBlock(result: SingleResult, maxResultLines: numb
125
127
  lines.push(`Relation: ${relations.join(" · ")}`, "");
126
128
  }
127
129
  lines.push(text);
128
- // A run can exit cleanly while its last tools failed (e.g. a build that broke):
129
- // the final text alone may claim more than the tools achieved, so surface the
130
- // failures explicitly and tell the main agent to verify before relying on it.
131
- if (!failed && failedTools.length > 0) {
132
- const shown = failedTools.slice(0, 3);
133
- const more = failedTools.length - shown.length;
130
+ // Explicit status always exposes every retained diagnostic, including when
131
+ // the overall run failed or was aborted. Automatic delivery adds only a
132
+ // compact pointer for otherwise-clean runs.
133
+ if (options.failedToolDetails && failedTools.length > 0) {
134
134
  lines.push(
135
135
  "",
136
- `⚠ ${failedTools.length} tool call${failedTools.length === 1 ? "" : "s"} failed during this run — the final text above may not reflect a working state:`,
137
- ...shown.map((tool) => `- ${tool.toolName}: ${tool.error.trim() || "(no output)"}`),
136
+ `⚠ ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}:`,
137
+ ...failedTools.map((tool) => `- ${tool.toolName}: ${tool.error.trim() || "(no output)"}`),
138
138
  );
139
- if (more > 0) lines.push(`- … and ${more} more`);
140
- lines.push("Verify the actual artifacts before relying on this report.");
139
+ } else if (!failed && failedTools.length > 0) {
140
+ const lookup = result.runId !== undefined ? ` · details: subagent_status #${result.runId}` : "";
141
+ lines.push("", `⚠ ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}${lookup}`);
141
142
  }
142
143
  if (truncated) {
143
144
  // The full text lives on disk so the main agent can read it on demand.
144
- lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent, cwd)})`);
145
+ lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent, result.projectCwd ?? cwd)})`);
145
146
  }
146
147
  return lines.join("\n");
147
148
  }
@@ -153,15 +154,12 @@ export function formatCompletionBlock(result: SingleResult, maxResultLines: numb
153
154
  * RESUME it in-context once a model is available, instead of re-dispatching
154
155
  * fresh (which would re-scan everything). */
155
156
  export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: number }): string {
156
- const sameModel = result.modelRetries
157
- ? `, after ${result.modelRetries} same-model retr${result.modelRetries === 1 ? "y" : "ies"} on transient errors`
158
- : "";
159
- const retry = result.modelFallbackFrom ? ", and the configured backup chain also failed" : "";
157
+ const retry = result.modelFallbackFrom ? ", and the current main model also failed" : "";
160
158
  const sessionPreserved = Boolean(result.sessionDir && result.sessionId) && opts?.runId !== undefined;
161
159
  const recovery = sessionPreserved
162
160
  ? ` The sub-agent's earlier work in this run is preserved. Once a model is available again, call subagent_control with { action: "resume", id: ${opts!.runId} } to CONTINUE it in-context (it keeps the same run id and does not re-scan), or execute the task in the main window with your own tools.`
163
161
  : ` Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
164
- return `The sub-agent could not complete this task: its model was unavailable or failed (or the run stalled)${sameModel}${retry}.${recovery}`;
162
+ return `The sub-agent could not complete this task: its model was unavailable or failed (or the run stalled)${retry}.${recovery}`;
165
163
  }
166
164
 
167
165
  /** Resolve a run-id request to actual ids: an exact numeric match always wins
package/src/index.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  * The heavy lifting lives in focused modules:
6
6
  * - dispatch.ts — the `subagent` tool (spawn, auto-fix chain, vision model)
7
7
  * - tools.ts — subagent_control / subagent_wait / status / stop
8
- * - announcements.ts — session-start recovery and feature notices
8
+ * - announcements.ts — session-start recovery, notices, and widget install
9
+ * - widget.ts — active-only TUI run status
9
10
  * - runtime.ts — shared per-session state
10
11
  *
11
12
  * Also registers the `/subagents-setup` command and a `before_agent_start` hook
@@ -28,6 +29,7 @@ import { createRuntime } from "./runtime.ts";
28
29
  import { runSetup } from "./setup.ts";
29
30
  import { currentSubagentDepth } from "./spawn.ts";
30
31
  import { registerLookupTools } from "./tools.ts";
32
+ import { clearActiveRunsWidget } from "./widget.ts";
31
33
 
32
34
  export { matchRunIds };
33
35
 
@@ -57,7 +59,8 @@ export default function (pi: ExtensionAPI): void {
57
59
  ),
58
60
  );
59
61
 
60
- pi.on("session_shutdown", async () => {
62
+ pi.on("session_shutdown", async (_event, ctx) => {
63
+ clearActiveRunsWidget(ctx);
61
64
  await runtime.shutdown();
62
65
  });
63
66
 
@@ -65,7 +68,7 @@ export default function (pi: ExtensionAPI): void {
65
68
  registerLookupTools(pi, runtime);
66
69
 
67
70
  pi.registerCommand("subagents-setup", {
68
- description: "Configure pi-subagents: enabled agents, primary/backup model pools, and runtime settings",
71
+ description: "Configure pi-subagents: agents, selected models, capability-aware thinking, vision, and runtime settings",
69
72
  handler: async (_args, ctx) => {
70
73
  await runSetup(ctx, configPath);
71
74
  },
package/src/models.ts CHANGED
@@ -1,61 +1,55 @@
1
1
  /*
2
- * Model-pool resolution and setup-picker helpers.
2
+ * Model routing, capability-aware thinking, and setup-picker helpers.
3
3
  *
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.
4
+ * Runtime has one explicit fallback only: a configured agent/vision model hands
5
+ * off directly to the current main-window model. Setup lists only currently
6
+ * available models and derives thinking choices from Pi's model metadata.
8
7
  */
9
8
 
10
- import type { Api, Model } from "@earendil-works/pi-ai";
9
+ import {
10
+ clampThinkingLevel,
11
+ getSupportedThinkingLevels,
12
+ type Api,
13
+ type Model,
14
+ } from "@earendil-works/pi-ai";
11
15
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
12
17
 
13
18
  export type ModelContext = Pick<ExtensionContext, "model" | "modelRegistry"> &
14
19
  Partial<Pick<ExtensionContext, "scopedModels">>;
15
20
 
16
21
  export const CURRENT_MAIN_MODEL = "__current_main_model__";
17
22
 
18
- export type ModelPoolSlot = "primary" | "backup";
19
- export type ModelPickerSlot = ModelPoolSlot | "vision";
23
+ export type ModelPickerSlot = "agent" | "vision";
20
24
 
21
25
  export interface ModelPickerItem {
22
26
  value: string;
23
27
  label: string;
24
28
  description?: string;
25
- /** Visible for diagnosis/search, but cannot be selected. */
26
- disabled?: boolean;
27
29
  }
28
30
 
29
31
  export type ModelListEntry = Pick<
30
32
  Model<Api>,
31
- "provider" | "id" | "name" | "input" | "reasoning"
33
+ "provider" | "id" | "name" | "input" | "reasoning" | "thinkingLevelMap"
32
34
  >;
33
35
 
34
- export interface ResolvedAgentModelPool {
35
- /** Effective first candidate. Undefined means let pi use its normal default. */
36
+ export interface ResolvedAgentModelRoute {
37
+ /** Effective first candidate. Undefined means let Pi use its normal default. */
36
38
  primaryRef?: string;
37
- /** Ordered candidates after the primary, already deduplicated. */
38
- fallbackModelRefs: string[];
39
- /** All known references in runtime order, useful for tests/inspection. */
39
+ /** Current main-window model when it differs from the selection. */
40
+ mainFallbackRef?: string;
41
+ /** Runtime order, useful for status/tests. */
40
42
  candidateRefs: string[];
43
+ /** Configured ref skipped because Pi does not currently report it available. */
44
+ unavailableSelectedRef?: string;
41
45
  }
42
46
 
43
- export interface AgentModelPoolInput {
44
- primaryRef?: string;
45
- backupRef?: string;
47
+ export interface AgentModelRouteInput {
48
+ selectedRef?: string;
46
49
  mainRef?: string;
47
50
  declaredDefaultRef?: string;
48
- }
49
-
50
- export interface AgentModelPoolMaps {
51
- agentModels: Record<string, string>;
52
- agentBackupModels: Record<string, string>;
53
- }
54
-
55
- export interface AgentModelPoolRow {
56
- name: string;
57
- primary: string;
58
- backup: string;
51
+ /** When supplied, a configured selection outside this live set is skipped. */
52
+ availableRefs?: readonly string[];
59
53
  }
60
54
 
61
55
  function cleanModelRef(ref: string | undefined): string | undefined {
@@ -78,70 +72,84 @@ export function currentModelRef(ctx: Pick<ModelContext, "model">): string | unde
78
72
  */
79
73
  export function availableModelsInScope(ctx: ModelContext): readonly Model<Api>[] {
80
74
  const models = ctx.modelRegistry.getAvailable();
81
- // scopedModels was added after the declared Pi 0.80.6 minimum. Treat a
82
- // missing field exactly like an empty scope and use the full live registry.
75
+ // scopedModels was added after the original Pi minimum. Treat a missing field
76
+ // exactly like an empty scope and use the full live registry.
83
77
  const scopedModels = ctx.scopedModels ?? [];
84
78
  if (scopedModels.length === 0) return models;
85
79
  const scopedRefs = new Set(scopedModels.map((entry) => modelRef(entry.model)));
86
80
  return models.filter((model) => scopedRefs.has(modelRef(model)));
87
81
  }
88
82
 
89
- /** Model refs usable by setup, with an available current main model first. */
90
- export function availableModelRefs(ctx: ModelContext): string[] {
91
- const refs = [...new Set(availableModelsInScope(ctx).map(modelRef))];
92
- const currentRef = currentModelRef(ctx);
93
- if (!currentRef || !refs.includes(currentRef)) return refs;
94
- return [currentRef, ...refs.filter((ref) => ref !== currentRef)];
83
+ export function findModelByRef(
84
+ models: readonly Model<Api>[],
85
+ ref: string | undefined,
86
+ ): Model<Api> | undefined {
87
+ const normalized = cleanModelRef(ref);
88
+ return normalized ? models.find((model) => modelRef(model) === normalized) : undefined;
95
89
  }
96
90
 
97
91
  /**
98
- * Resolve one agent's ordered runtime pool:
92
+ * Resolve one agent's runtime route:
99
93
  *
100
- * configured primary -> configured backup -> current main-window model
94
+ * configured selection -> current main-window model
101
95
  *
102
- * Without a primary override, the current main model remains the primary; an
103
- * agent-declared default is used only when no main model exists. Equal refs are
104
- * removed without consulting availability, so stale refs stay in the chain and
105
- * fail normally at runtime instead of being silently repaired.
96
+ * Without an override, current main is primary; the agent-declared default is
97
+ * used only when no main model exists. A configured selection that Pi no longer
98
+ * reports as available is skipped immediately instead of spawning a doomed child.
106
99
  */
107
- export function resolveAgentModelPool(input: AgentModelPoolInput): ResolvedAgentModelPool {
100
+ export function resolveAgentModelRoute(input: AgentModelRouteInput): ResolvedAgentModelRoute {
101
+ const selectedRef = cleanModelRef(input.selectedRef);
108
102
  const mainRef = cleanModelRef(input.mainRef);
109
- const primaryRef = cleanModelRef(input.primaryRef) ?? mainRef ?? cleanModelRef(input.declaredDefaultRef);
110
- const ordered = [primaryRef, cleanModelRef(input.backupRef), mainRef];
111
- const seen = new Set<string>();
112
- const candidateRefs: string[] = [];
113
- for (const ref of ordered) {
114
- if (!ref || seen.has(ref)) continue;
115
- seen.add(ref);
116
- candidateRefs.push(ref);
117
- }
118
- const fallbackModelRefs = candidateRefs.filter((ref) => ref !== primaryRef);
119
- return { primaryRef, fallbackModelRefs, candidateRefs };
103
+ const declaredDefaultRef = cleanModelRef(input.declaredDefaultRef);
104
+ const available = input.availableRefs
105
+ ? new Set(input.availableRefs.map((ref) => ref.trim()).filter(Boolean))
106
+ : undefined;
107
+ const selectedAvailable = !selectedRef || !available || available.has(selectedRef);
108
+ const usableSelectedRef = selectedAvailable ? selectedRef : undefined;
109
+ const primaryRef = usableSelectedRef ?? mainRef ?? declaredDefaultRef;
110
+ const ordered = [primaryRef, usableSelectedRef && usableSelectedRef !== mainRef ? mainRef : undefined];
111
+ const candidateRefs = [...new Set(ordered.filter((ref): ref is string => Boolean(ref)))];
112
+ return {
113
+ primaryRef,
114
+ ...(candidateRefs[1] ? { mainFallbackRef: candidateRefs[1] } : {}),
115
+ candidateRefs,
116
+ ...(!selectedAvailable && selectedRef ? { unavailableSelectedRef: selectedRef } : {}),
117
+ };
118
+ }
119
+
120
+ /** The exact levels Pi exposes for this model, including `off` when supported. */
121
+ export function supportedThinkingLevels(model: Model<Api> | undefined): ThinkingLevel[] {
122
+ return model ? (getSupportedThinkingLevels(model) as ThinkingLevel[]) : [];
123
+ }
124
+
125
+ /** Clamp an agent preference to the effective model's actual capability map. */
126
+ export function resolveThinkingLevel(
127
+ model: Model<Api> | undefined,
128
+ preferred: ThinkingLevel = DEFAULT_THINKING_LEVEL,
129
+ ): ThinkingLevel {
130
+ return model ? (clampThinkingLevel(model, preferred) as ThinkingLevel) : preferred;
120
131
  }
121
132
 
122
133
  function modelCapabilities(model: ModelListEntry): string {
123
- const capabilities = [model.input.includes("image") ? "vision" : "text-only"];
124
- if (model.reasoning) capabilities.push("reasoning");
125
- return capabilities.join(" + ");
134
+ const input = model.input.includes("image") ? "vision" : "text-only";
135
+ const thinking = getSupportedThinkingLevels(model as Model<Api>).join("/");
136
+ return `${input} · thinking: ${thinking}`;
126
137
  }
127
138
 
128
- /** Build the single searchable model list shared by primary/backup/vision picks.
129
- * Only refs Pi currently reports as available are shown, which means providers
130
- * without a configured API key/OAuth session never flood the setup picker. */
139
+ /** Build one searchable list for agent or vision selection. Only models Pi
140
+ * currently reports as available are supplied by setup. */
131
141
  export function buildModelPickerItems(options: {
132
142
  models: readonly ModelListEntry[];
133
- availableRefs: readonly string[];
134
143
  slot: ModelPickerSlot;
135
144
  configuredRef?: string;
136
145
  mainRef?: string;
137
146
  }): ModelPickerItem[] {
138
147
  const configuredRef = cleanModelRef(options.configuredRef);
139
148
  const mainRef = cleanModelRef(options.mainRef);
140
- const available = new Set(options.availableRefs.map((ref) => ref.trim()));
141
149
  const byRef = new Map<string, ModelListEntry>();
142
150
  for (const model of options.models) {
143
151
  const ref = modelRef(model);
144
- if (available.has(ref) && !byRef.has(ref)) byRef.set(ref, model);
152
+ if (!byRef.has(ref)) byRef.set(ref, model);
145
153
  }
146
154
 
147
155
  const refs = [...byRef.keys()]
@@ -152,26 +160,18 @@ export function buildModelPickerItems(options: {
152
160
  return leftRank - rightRank || left.localeCompare(right);
153
161
  });
154
162
 
155
- const dynamic = options.slot === "backup"
156
- ? {
157
- value: CURRENT_MAIN_MODEL,
158
- label: "Current main model (default)",
159
- description: "Clear configured backup; use the main-window model dynamically",
160
- }
161
- : {
162
- value: CURRENT_MAIN_MODEL,
163
- label: "Current main model (dynamic)",
164
- description: options.slot === "vision"
165
- ? "Clear vision override; use the main-window model for vision tasks"
166
- : "Clear primary override; use the main-window model dynamically",
167
- };
168
-
163
+ const dynamic: ModelPickerItem = {
164
+ value: CURRENT_MAIN_MODEL,
165
+ label: "Current main model (dynamic)",
166
+ description: options.slot === "vision"
167
+ ? "Clear vision override; use the current main model for image tasks"
168
+ : "Clear agent override; use the current main model dynamically",
169
+ };
169
170
  const items: ModelPickerItem[] = [dynamic];
170
171
  for (const ref of refs) {
171
172
  const model = byRef.get(ref)!;
172
- const tags = ["available"];
173
- if (ref === configuredRef) tags.push("configured");
174
- if (ref === mainRef) tags.push("current main");
173
+ const tags = [ref === configuredRef ? "configured" : "", ref === mainRef ? "current main" : ""]
174
+ .filter(Boolean);
175
175
  const name = model.name.trim() && model.name !== model.id ? model.name.trim() : undefined;
176
176
  items.push({
177
177
  value: ref,
@@ -182,31 +182,14 @@ export function buildModelPickerItems(options: {
182
182
  return items;
183
183
  }
184
184
 
185
- /** Pure pool update: the dynamic/default choice removes the persisted override. */
186
- export function applyModelPoolChoice(
187
- current: AgentModelPoolMaps,
185
+ /** The dynamic choice removes the persisted per-agent override. */
186
+ export function applyAgentModelChoice(
187
+ current: Record<string, string>,
188
188
  agentName: string,
189
- slot: ModelPoolSlot,
190
189
  choice: string,
191
- ): AgentModelPoolMaps {
192
- const next: AgentModelPoolMaps = {
193
- agentModels: { ...current.agentModels },
194
- agentBackupModels: { ...current.agentBackupModels },
195
- };
196
- const target = slot === "primary" ? next.agentModels : next.agentBackupModels;
197
- if (choice === CURRENT_MAIN_MODEL) delete target[agentName];
198
- else target[agentName] = choice.trim();
190
+ ): Record<string, string> {
191
+ const next = { ...current };
192
+ if (choice === CURRENT_MAIN_MODEL) delete next[agentName];
193
+ else next[agentName] = choice.trim();
199
194
  return next;
200
195
  }
201
-
202
- /** Pure rows used by the overview component and focused helper tests. */
203
- export function buildAgentModelPoolRows(
204
- agentNames: readonly string[],
205
- pools: AgentModelPoolMaps,
206
- ): AgentModelPoolRow[] {
207
- return agentNames.map((name) => ({
208
- name,
209
- primary: pools.agentModels[name] ?? "Main (dynamic)",
210
- backup: pools.agentBackupModels[name] ?? "Main (default)",
211
- }));
212
- }