@ferris1225/pi-subagents 4.0.1 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.ts CHANGED
@@ -27,6 +27,20 @@ export type AgentScope = (typeof AGENT_SCOPE_VALUES)[number];
27
27
 
28
28
  const LEGACY_EXPLORER_NAME = "explore";
29
29
  const EXPLORER_NAME = "explorer";
30
+ const CLEANER_NAME = "cleaner";
31
+ const REVIEWER_NAME = "reviewer";
32
+
33
+ /**
34
+ * Stamps recorded in `announcedFeatures` by the one-time upgrade that defaults
35
+ * cleaner on for configs written before it shipped. The first marks a config as
36
+ * processed, so a later deliberate disable is not undone; the second records
37
+ * that cleaner was actually injected, so session start can tell the user once;
38
+ * the third records that reviewer model/thinking settings were actually copied,
39
+ * so that notice never claims an inheritance that did not happen.
40
+ */
41
+ export const CLEANER_DEFAULTED_FEATURE = "cleanerDefaulted";
42
+ export const CLEANER_AUTO_ENABLED_FEATURE = "cleanerAutoEnabled";
43
+ export const CLEANER_INHERITED_FEATURE = "cleanerInheritedReviewer";
30
44
 
31
45
  function migrateAgentName(name: string): string {
32
46
  return name === LEGACY_EXPLORER_NAME ? EXPLORER_NAME : name;
@@ -106,8 +120,9 @@ export interface SubagentsConfig {
106
120
  */
107
121
  idleTimeoutSec: number;
108
122
  /**
109
- * One-time feature announcements already shown to the user. Persisted so
110
- * the notice never nags again.
123
+ * One-time feature announcements already shown to the user, plus schema
124
+ * upgrade stamps (e.g. the cleaner default-enable upgrade). Persisted so
125
+ * notices and migrations never repeat.
111
126
  */
112
127
  announcedFeatures: string[];
113
128
  }
@@ -241,6 +256,32 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
241
256
  );
242
257
  }
243
258
 
259
+ // One-time upgrade for configs written before cleaner shipped: a non-empty
260
+ // explicit enabledAgents list gets cleaner defaulted on (inserted before
261
+ // reviewer, matching the fresh-install order) and inherits the reviewer's
262
+ // configured model and thinking level — its closest peer. The stamps make
263
+ // the upgrade idempotent and keep a later deliberate disable from being undone.
264
+ if (!config.announcedFeatures.includes(CLEANER_DEFAULTED_FEATURE)) {
265
+ config.announcedFeatures.push(CLEANER_DEFAULTED_FEATURE);
266
+ if (config.enabledAgents.length > 0 && !config.enabledAgents.includes(CLEANER_NAME)) {
267
+ const reviewerIndex = config.enabledAgents.indexOf(REVIEWER_NAME);
268
+ config.enabledAgents.splice(reviewerIndex === -1 ? config.enabledAgents.length : reviewerIndex, 0, CLEANER_NAME);
269
+ config.announcedFeatures.push(CLEANER_AUTO_ENABLED_FEATURE);
270
+ let inherited = false;
271
+ if (!config.agentModels[CLEANER_NAME] && config.agentModels[REVIEWER_NAME]) {
272
+ config.agentModels[CLEANER_NAME] = config.agentModels[REVIEWER_NAME];
273
+ inherited = true;
274
+ }
275
+ if (!config.agentThinkingLevels[CLEANER_NAME] && config.agentThinkingLevels[REVIEWER_NAME]) {
276
+ config.agentThinkingLevels[CLEANER_NAME] = config.agentThinkingLevels[REVIEWER_NAME];
277
+ inherited = true;
278
+ }
279
+ // An old explicit list may have no reviewer overrides to copy; only the
280
+ // copied case is stamped so the one-time notice stays accurate.
281
+ if (inherited) config.announcedFeatures.push(CLEANER_INHERITED_FEATURE);
282
+ }
283
+ }
284
+
244
285
  return config;
245
286
  }
246
287
 
package/src/index.ts CHANGED
@@ -1,93 +1,93 @@
1
- /**
2
- * pi-subagents — focused sub-agent delegation for pi.
3
- *
4
- * Assembly point: builds the shared runtime and registers everything.
5
- * The heavy lifting lives in focused modules:
6
- * - dispatch.ts — the `subagent` tool contract and auto-fix chain
7
- * - thread-lifecycle.ts — queued generations, resume/fork, isolation settlement
8
- * - tools.ts — subagent_control / subagent_wait / status / stop
9
- * - announcements.ts — session-start recovery, notices, and widget install
10
- * - widget.ts — active-only TUI run status
11
- * - runtime.ts — shared per-session state
12
- *
13
- * Also registers the `/subagents-setup` command and a `before_agent_start` hook
14
- * that injects a delegation directive into the parent system prompt so the main
15
- * model uses the tool proactively.
16
- *
17
- * The tool is not registered inside child sub-agent processes, which prevents
18
- * runaway recursion and keeps child context windows clean.
19
- */
20
-
21
- import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
- import { Text } from "@earendil-works/pi-tui";
23
- import { discoverAgents } from "./agents.ts";
24
- import { registerAnnouncements } from "./announcements.ts";
25
- import { getConfigPath, loadConfig } from "./config.ts";
26
- import { registerSubagentTool } from "./dispatch.ts";
27
- import { matchRunIds } from "./format.ts";
28
- import { buildDelegationDirective } from "./prompt.ts";
29
- import { createRuntime } from "./runtime.ts";
30
- import { runSetup } from "./setup.ts";
31
- import { currentSubagentDepth } from "./spawn.ts";
32
- import { registerLookupTools } from "./tools.ts";
33
- import { clearActiveRunsWidget } from "./widget.ts";
34
-
35
- export { matchRunIds };
36
-
37
- export default function (pi: ExtensionAPI): void {
38
- const configPath = getConfigPath(getAgentDir());
39
- const runtime = createRuntime(pi, configPath);
40
-
41
- // Recursion guard: sub-agent children are leaf processes. The `subagent` tool is
42
- // excluded from their toolset at spawn (--exclude-tools); this check is defense
43
- // in depth so a child can never expose the tool back to its model, even if
44
- // another extension ignores the depth marker.
45
- if (currentSubagentDepth() >= 1) {
46
- pi.registerCommand("subagents-setup", {
47
- description: "Configure pi-subagents (unavailable in nested sub-agent processes)",
48
- handler: async (_args, ctx) => {
49
- ctx.ui.notify("pi-subagents setup is unavailable in nested sub-agent processes.", "warning");
50
- },
51
- });
52
- return;
53
- }
54
-
55
- pi.registerMessageRenderer("subagent-result", (message, _options, theme) =>
56
- new Text(
57
- `${theme.fg("toolTitle", theme.bold("subagent result"))}\n${message.content}`,
58
- 0,
59
- 0,
60
- ),
61
- );
62
-
63
- pi.on("session_shutdown", async (_event, ctx) => {
64
- clearActiveRunsWidget(ctx);
65
- await runtime.shutdown();
66
- });
67
-
68
- registerSubagentTool(pi, runtime);
69
- registerLookupTools(pi, runtime);
70
-
71
- pi.registerCommand("subagents-setup", {
72
- description: "Configure pi-subagents: agents, selected models, capability-aware thinking, and runtime settings",
73
- handler: async (_args, ctx) => {
74
- await runSetup(ctx, configPath);
75
- },
76
- });
77
-
78
- registerAnnouncements(pi, runtime);
79
-
80
- // Proactive dispatch: inject the delegation directive into the parent system prompt.
81
- pi.on("before_agent_start", async (event, ctx) => {
82
- const config = await loadConfig(configPath);
83
- if (!config.proactiveInjection) return undefined;
84
- const { agents } = discoverAgents(ctx.cwd, {
85
- scope: config.agentScope,
86
- enabledNames: config.enabledAgents,
87
- projectTrusted: ctx.isProjectTrusted?.() === true,
88
- });
89
- const directive = buildDelegationDirective(agents);
90
- if (!directive) return undefined;
91
- return { systemPrompt: `${event.systemPrompt}\n${directive}` };
92
- });
93
- }
1
+ /**
2
+ * pi-subagents — focused sub-agent delegation for pi.
3
+ *
4
+ * Assembly point: builds the shared runtime and registers everything.
5
+ * The heavy lifting lives in focused modules:
6
+ * - dispatch.ts — the `subagent` tool contract and auto-fix chain
7
+ * - thread-lifecycle.ts — queued generations, resume/fork, isolation settlement
8
+ * - tools.ts — subagent_control / subagent_wait / status / stop
9
+ * - announcements.ts — session-start recovery, notices, and widget install
10
+ * - widget.ts — active-only TUI run status
11
+ * - runtime.ts — shared per-session state
12
+ *
13
+ * Also registers the `/subagents-setup` command and a `before_agent_start` hook
14
+ * that injects a delegation directive into the parent system prompt so the main
15
+ * model uses the tool proactively.
16
+ *
17
+ * The tool is not registered inside child sub-agent processes, which prevents
18
+ * runaway recursion and keeps child context windows clean.
19
+ */
20
+
21
+ import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
+ import { Text } from "@earendil-works/pi-tui";
23
+ import { discoverAgents } from "./agents.ts";
24
+ import { registerAnnouncements } from "./announcements.ts";
25
+ import { getConfigPath, loadConfig } from "./config.ts";
26
+ import { registerSubagentTool } from "./dispatch.ts";
27
+ import { matchRunIds } from "./format.ts";
28
+ import { buildDelegationDirective } from "./prompt.ts";
29
+ import { createRuntime } from "./runtime.ts";
30
+ import { runSetup } from "./setup.ts";
31
+ import { currentSubagentDepth } from "./spawn.ts";
32
+ import { registerLookupTools } from "./tools.ts";
33
+ import { clearActiveRunsWidget } from "./widget.ts";
34
+
35
+ export { matchRunIds };
36
+
37
+ export default function (pi: ExtensionAPI): void {
38
+ const configPath = getConfigPath(getAgentDir());
39
+ const runtime = createRuntime(pi, configPath);
40
+
41
+ // Recursion guard: sub-agent children are leaf processes. The `subagent` tool is
42
+ // excluded from their toolset at spawn (--exclude-tools); this check is defense
43
+ // in depth so a child can never expose the tool back to its model, even if
44
+ // another extension ignores the depth marker.
45
+ if (currentSubagentDepth() >= 1) {
46
+ pi.registerCommand("subagents-setup", {
47
+ description: "Configure pi-subagents (unavailable in nested sub-agent processes)",
48
+ handler: async (_args, ctx) => {
49
+ ctx.ui.notify("pi-subagents setup is unavailable in nested sub-agent processes.", "warning");
50
+ },
51
+ });
52
+ return;
53
+ }
54
+
55
+ pi.registerMessageRenderer("subagent-result", (message, _options, theme) =>
56
+ new Text(
57
+ `${theme.fg("toolTitle", theme.bold("subagent result"))}\n${message.content}`,
58
+ 0,
59
+ 0,
60
+ ),
61
+ );
62
+
63
+ pi.on("session_shutdown", async (_event, ctx) => {
64
+ clearActiveRunsWidget(ctx);
65
+ await runtime.shutdown();
66
+ });
67
+
68
+ registerSubagentTool(pi, runtime);
69
+ registerLookupTools(pi, runtime);
70
+
71
+ pi.registerCommand("subagents-setup", {
72
+ description: "Configure pi-subagents: agents, selected models, capability-aware thinking, and runtime settings",
73
+ handler: async (_args, ctx) => {
74
+ await runSetup(ctx, configPath);
75
+ },
76
+ });
77
+
78
+ registerAnnouncements(pi, runtime);
79
+
80
+ // Proactive dispatch: inject the delegation directive into the parent system prompt.
81
+ pi.on("before_agent_start", async (event, ctx) => {
82
+ const config = await loadConfig(configPath);
83
+ if (!config.proactiveInjection) return undefined;
84
+ const { agents } = discoverAgents(ctx.cwd, {
85
+ scope: config.agentScope,
86
+ enabledNames: config.enabledAgents,
87
+ projectTrusted: ctx.isProjectTrusted?.() === true,
88
+ });
89
+ const directive = buildDelegationDirective(agents);
90
+ if (!directive) return undefined;
91
+ return { systemPrompt: `${event.systemPrompt}\n${directive}` };
92
+ });
93
+ }
package/src/models.ts CHANGED
@@ -1,189 +1,189 @@
1
- /*
2
- * Model routing, capability-aware thinking, and setup-picker helpers.
3
- *
4
- * Runtime has one explicit fallback only: a configured agent 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.
7
- */
8
-
9
- import {
10
- clampThinkingLevel,
11
- getSupportedThinkingLevels,
12
- type Api,
13
- type Model,
14
- } from "@earendil-works/pi-ai";
15
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
16
- import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
17
-
18
- export type ModelContext = Pick<ExtensionContext, "model" | "modelRegistry"> &
19
- Partial<Pick<ExtensionContext, "scopedModels">>;
20
-
21
- export const CURRENT_MAIN_MODEL = "__current_main_model__";
22
-
23
- export interface ModelPickerItem {
24
- value: string;
25
- label: string;
26
- description?: string;
27
- }
28
-
29
- export type ModelListEntry = Pick<
30
- Model<Api>,
31
- "provider" | "id" | "name" | "input" | "reasoning" | "thinkingLevelMap"
32
- >;
33
-
34
- export interface ResolvedAgentModelRoute {
35
- /** Effective first candidate. Undefined means let Pi use its normal default. */
36
- primaryRef?: string;
37
- /** Current main-window model when it differs from the selection. */
38
- mainFallbackRef?: string;
39
- /** Runtime order, useful for status/tests. */
40
- candidateRefs: string[];
41
- /** Configured ref skipped because Pi does not currently report it available. */
42
- unavailableSelectedRef?: string;
43
- }
44
-
45
- export interface AgentModelRouteInput {
46
- selectedRef?: string;
47
- mainRef?: string;
48
- declaredDefaultRef?: string;
49
- /** When supplied, a configured selection outside this live set is skipped. */
50
- availableRefs?: readonly string[];
51
- }
52
-
53
- function cleanModelRef(ref: string | undefined): string | undefined {
54
- const trimmed = ref?.trim();
55
- return trimmed || undefined;
56
- }
57
-
58
- export function modelRef(model: { provider: string; id: string }): string {
59
- return `${model.provider}/${model.id}`;
60
- }
61
-
62
- export function currentModelRef(ctx: Pick<ModelContext, "model">): string | undefined {
63
- return ctx.model ? modelRef(ctx.model) : undefined;
64
- }
65
-
66
- /**
67
- * Current authenticated registry models narrowed by the session scope. Scope
68
- * entries are a session snapshot, so they act only as a whitelist; the live
69
- * registry remains the source of truth for availability and model metadata.
70
- */
71
- export function availableModelsInScope(ctx: ModelContext): readonly Model<Api>[] {
72
- const models = ctx.modelRegistry.getAvailable();
73
- // scopedModels was added after the original Pi minimum. Treat a missing field
74
- // exactly like an empty scope and use the full live registry.
75
- const scopedModels = ctx.scopedModels ?? [];
76
- if (scopedModels.length === 0) return models;
77
- const scopedRefs = new Set(scopedModels.map((entry) => modelRef(entry.model)));
78
- return models.filter((model) => scopedRefs.has(modelRef(model)));
79
- }
80
-
81
- export function findModelByRef(
82
- models: readonly Model<Api>[],
83
- ref: string | undefined,
84
- ): Model<Api> | undefined {
85
- const normalized = cleanModelRef(ref);
86
- return normalized ? models.find((model) => modelRef(model) === normalized) : undefined;
87
- }
88
-
89
- /**
90
- * Resolve one agent's runtime route:
91
- *
92
- * configured selection -> current main-window model
93
- *
94
- * Without an override, current main is primary; the agent-declared default is
95
- * used only when no main model exists. A configured selection that Pi no longer
96
- * reports as available is skipped immediately instead of spawning a doomed child.
97
- */
98
- export function resolveAgentModelRoute(input: AgentModelRouteInput): ResolvedAgentModelRoute {
99
- const selectedRef = cleanModelRef(input.selectedRef);
100
- const mainRef = cleanModelRef(input.mainRef);
101
- const declaredDefaultRef = cleanModelRef(input.declaredDefaultRef);
102
- const available = input.availableRefs
103
- ? new Set(input.availableRefs.map((ref) => ref.trim()).filter(Boolean))
104
- : undefined;
105
- const selectedAvailable = !selectedRef || !available || available.has(selectedRef);
106
- const usableSelectedRef = selectedAvailable ? selectedRef : undefined;
107
- const primaryRef = usableSelectedRef ?? mainRef ?? declaredDefaultRef;
108
- const ordered = [primaryRef, usableSelectedRef && usableSelectedRef !== mainRef ? mainRef : undefined];
109
- const candidateRefs = [...new Set(ordered.filter((ref): ref is string => Boolean(ref)))];
110
- return {
111
- primaryRef,
112
- ...(candidateRefs[1] ? { mainFallbackRef: candidateRefs[1] } : {}),
113
- candidateRefs,
114
- ...(!selectedAvailable && selectedRef ? { unavailableSelectedRef: selectedRef } : {}),
115
- };
116
- }
117
-
118
- /** The exact levels Pi exposes for this model, including `off` when supported. */
119
- export function supportedThinkingLevels(model: Model<Api> | undefined): ThinkingLevel[] {
120
- return model ? (getSupportedThinkingLevels(model) as ThinkingLevel[]) : [];
121
- }
122
-
123
- /** Clamp an agent preference to the effective model's actual capability map. */
124
- export function resolveThinkingLevel(
125
- model: Model<Api> | undefined,
126
- preferred: ThinkingLevel = DEFAULT_THINKING_LEVEL,
127
- ): ThinkingLevel {
128
- return model ? (clampThinkingLevel(model, preferred) as ThinkingLevel) : preferred;
129
- }
130
-
131
- function modelCapabilities(model: ModelListEntry): string {
132
- const input = model.input.includes("image") ? "vision" : "text-only";
133
- const thinking = getSupportedThinkingLevels(model as Model<Api>).join("/");
134
- return `${input} · thinking: ${thinking}`;
135
- }
136
-
137
- /** Build one searchable list for agent model selection. Only models Pi
138
- * currently reports as available are supplied by setup. */
139
- export function buildModelPickerItems(options: {
140
- models: readonly ModelListEntry[];
141
- configuredRef?: string;
142
- mainRef?: string;
143
- }): ModelPickerItem[] {
144
- const configuredRef = cleanModelRef(options.configuredRef);
145
- const mainRef = cleanModelRef(options.mainRef);
146
- const byRef = new Map<string, ModelListEntry>();
147
- for (const model of options.models) {
148
- const ref = modelRef(model);
149
- if (!byRef.has(ref)) byRef.set(ref, model);
150
- }
151
-
152
- const refs = [...byRef.keys()]
153
- .sort((left, right) => {
154
- const leftRank = left === configuredRef ? 0 : left === mainRef ? 1 : 2;
155
- const rightRank = right === configuredRef ? 0 : right === mainRef ? 1 : 2;
156
- return leftRank - rightRank || left.localeCompare(right);
157
- });
158
-
159
- const dynamic: ModelPickerItem = {
160
- value: CURRENT_MAIN_MODEL,
161
- label: "Current main model (dynamic)",
162
- description: "Clear agent override; use the current main model dynamically",
163
- };
164
- const items: ModelPickerItem[] = [dynamic];
165
- for (const ref of refs) {
166
- const model = byRef.get(ref)!;
167
- const tags = [ref === configuredRef ? "configured" : "", ref === mainRef ? "current main" : ""]
168
- .filter(Boolean);
169
- const name = model.name.trim() && model.name !== model.id ? model.name.trim() : undefined;
170
- items.push({
171
- value: ref,
172
- label: ref,
173
- description: [name, modelCapabilities(model), ...tags].filter(Boolean).join(" · "),
174
- });
175
- }
176
- return items;
177
- }
178
-
179
- /** The dynamic choice removes the persisted per-agent override. */
180
- export function applyAgentModelChoice(
181
- current: Record<string, string>,
182
- agentName: string,
183
- choice: string,
184
- ): Record<string, string> {
185
- const next = { ...current };
186
- if (choice === CURRENT_MAIN_MODEL) delete next[agentName];
187
- else next[agentName] = choice.trim();
188
- return next;
189
- }
1
+ /*
2
+ * Model routing, capability-aware thinking, and setup-picker helpers.
3
+ *
4
+ * Runtime has one explicit fallback only: a configured agent 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.
7
+ */
8
+
9
+ import {
10
+ clampThinkingLevel,
11
+ getSupportedThinkingLevels,
12
+ type Api,
13
+ type Model,
14
+ } from "@earendil-works/pi-ai";
15
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
17
+
18
+ export type ModelContext = Pick<ExtensionContext, "model" | "modelRegistry"> &
19
+ Partial<Pick<ExtensionContext, "scopedModels">>;
20
+
21
+ export const CURRENT_MAIN_MODEL = "__current_main_model__";
22
+
23
+ export interface ModelPickerItem {
24
+ value: string;
25
+ label: string;
26
+ description?: string;
27
+ }
28
+
29
+ export type ModelListEntry = Pick<
30
+ Model<Api>,
31
+ "provider" | "id" | "name" | "input" | "reasoning" | "thinkingLevelMap"
32
+ >;
33
+
34
+ export interface ResolvedAgentModelRoute {
35
+ /** Effective first candidate. Undefined means let Pi use its normal default. */
36
+ primaryRef?: string;
37
+ /** Current main-window model when it differs from the selection. */
38
+ mainFallbackRef?: string;
39
+ /** Runtime order, useful for status/tests. */
40
+ candidateRefs: string[];
41
+ /** Configured ref skipped because Pi does not currently report it available. */
42
+ unavailableSelectedRef?: string;
43
+ }
44
+
45
+ export interface AgentModelRouteInput {
46
+ selectedRef?: string;
47
+ mainRef?: string;
48
+ declaredDefaultRef?: string;
49
+ /** When supplied, a configured selection outside this live set is skipped. */
50
+ availableRefs?: readonly string[];
51
+ }
52
+
53
+ function cleanModelRef(ref: string | undefined): string | undefined {
54
+ const trimmed = ref?.trim();
55
+ return trimmed || undefined;
56
+ }
57
+
58
+ export function modelRef(model: { provider: string; id: string }): string {
59
+ return `${model.provider}/${model.id}`;
60
+ }
61
+
62
+ export function currentModelRef(ctx: Pick<ModelContext, "model">): string | undefined {
63
+ return ctx.model ? modelRef(ctx.model) : undefined;
64
+ }
65
+
66
+ /**
67
+ * Current authenticated registry models narrowed by the session scope. Scope
68
+ * entries are a session snapshot, so they act only as a whitelist; the live
69
+ * registry remains the source of truth for availability and model metadata.
70
+ */
71
+ export function availableModelsInScope(ctx: ModelContext): readonly Model<Api>[] {
72
+ const models = ctx.modelRegistry.getAvailable();
73
+ // scopedModels was added after the original Pi minimum. Treat a missing field
74
+ // exactly like an empty scope and use the full live registry.
75
+ const scopedModels = ctx.scopedModels ?? [];
76
+ if (scopedModels.length === 0) return models;
77
+ const scopedRefs = new Set(scopedModels.map((entry) => modelRef(entry.model)));
78
+ return models.filter((model) => scopedRefs.has(modelRef(model)));
79
+ }
80
+
81
+ export function findModelByRef(
82
+ models: readonly Model<Api>[],
83
+ ref: string | undefined,
84
+ ): Model<Api> | undefined {
85
+ const normalized = cleanModelRef(ref);
86
+ return normalized ? models.find((model) => modelRef(model) === normalized) : undefined;
87
+ }
88
+
89
+ /**
90
+ * Resolve one agent's runtime route:
91
+ *
92
+ * configured selection -> current main-window model
93
+ *
94
+ * Without an override, current main is primary; the agent-declared default is
95
+ * used only when no main model exists. A configured selection that Pi no longer
96
+ * reports as available is skipped immediately instead of spawning a doomed child.
97
+ */
98
+ export function resolveAgentModelRoute(input: AgentModelRouteInput): ResolvedAgentModelRoute {
99
+ const selectedRef = cleanModelRef(input.selectedRef);
100
+ const mainRef = cleanModelRef(input.mainRef);
101
+ const declaredDefaultRef = cleanModelRef(input.declaredDefaultRef);
102
+ const available = input.availableRefs
103
+ ? new Set(input.availableRefs.map((ref) => ref.trim()).filter(Boolean))
104
+ : undefined;
105
+ const selectedAvailable = !selectedRef || !available || available.has(selectedRef);
106
+ const usableSelectedRef = selectedAvailable ? selectedRef : undefined;
107
+ const primaryRef = usableSelectedRef ?? mainRef ?? declaredDefaultRef;
108
+ const ordered = [primaryRef, usableSelectedRef && usableSelectedRef !== mainRef ? mainRef : undefined];
109
+ const candidateRefs = [...new Set(ordered.filter((ref): ref is string => Boolean(ref)))];
110
+ return {
111
+ primaryRef,
112
+ ...(candidateRefs[1] ? { mainFallbackRef: candidateRefs[1] } : {}),
113
+ candidateRefs,
114
+ ...(!selectedAvailable && selectedRef ? { unavailableSelectedRef: selectedRef } : {}),
115
+ };
116
+ }
117
+
118
+ /** The exact levels Pi exposes for this model, including `off` when supported. */
119
+ export function supportedThinkingLevels(model: Model<Api> | undefined): ThinkingLevel[] {
120
+ return model ? (getSupportedThinkingLevels(model) as ThinkingLevel[]) : [];
121
+ }
122
+
123
+ /** Clamp an agent preference to the effective model's actual capability map. */
124
+ export function resolveThinkingLevel(
125
+ model: Model<Api> | undefined,
126
+ preferred: ThinkingLevel = DEFAULT_THINKING_LEVEL,
127
+ ): ThinkingLevel {
128
+ return model ? (clampThinkingLevel(model, preferred) as ThinkingLevel) : preferred;
129
+ }
130
+
131
+ function modelCapabilities(model: ModelListEntry): string {
132
+ const input = model.input.includes("image") ? "vision" : "text-only";
133
+ const thinking = getSupportedThinkingLevels(model as Model<Api>).join("/");
134
+ return `${input} · thinking: ${thinking}`;
135
+ }
136
+
137
+ /** Build one searchable list for agent model selection. Only models Pi
138
+ * currently reports as available are supplied by setup. */
139
+ export function buildModelPickerItems(options: {
140
+ models: readonly ModelListEntry[];
141
+ configuredRef?: string;
142
+ mainRef?: string;
143
+ }): ModelPickerItem[] {
144
+ const configuredRef = cleanModelRef(options.configuredRef);
145
+ const mainRef = cleanModelRef(options.mainRef);
146
+ const byRef = new Map<string, ModelListEntry>();
147
+ for (const model of options.models) {
148
+ const ref = modelRef(model);
149
+ if (!byRef.has(ref)) byRef.set(ref, model);
150
+ }
151
+
152
+ const refs = [...byRef.keys()]
153
+ .sort((left, right) => {
154
+ const leftRank = left === configuredRef ? 0 : left === mainRef ? 1 : 2;
155
+ const rightRank = right === configuredRef ? 0 : right === mainRef ? 1 : 2;
156
+ return leftRank - rightRank || left.localeCompare(right);
157
+ });
158
+
159
+ const dynamic: ModelPickerItem = {
160
+ value: CURRENT_MAIN_MODEL,
161
+ label: "Current main model (dynamic)",
162
+ description: "Clear agent override; use the current main model dynamically",
163
+ };
164
+ const items: ModelPickerItem[] = [dynamic];
165
+ for (const ref of refs) {
166
+ const model = byRef.get(ref)!;
167
+ const tags = [ref === configuredRef ? "configured" : "", ref === mainRef ? "current main" : ""]
168
+ .filter(Boolean);
169
+ const name = model.name.trim() && model.name !== model.id ? model.name.trim() : undefined;
170
+ items.push({
171
+ value: ref,
172
+ label: ref,
173
+ description: [name, modelCapabilities(model), ...tags].filter(Boolean).join(" · "),
174
+ });
175
+ }
176
+ return items;
177
+ }
178
+
179
+ /** The dynamic choice removes the persisted per-agent override. */
180
+ export function applyAgentModelChoice(
181
+ current: Record<string, string>,
182
+ agentName: string,
183
+ choice: string,
184
+ ): Record<string, string> {
185
+ const next = { ...current };
186
+ if (choice === CURRENT_MAIN_MODEL) delete next[agentName];
187
+ else next[agentName] = choice.trim();
188
+ return next;
189
+ }