@xl0/pi-lovely-agents 0.1.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 (29) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +184 -0
  4. package/extensions/lovely-agents/agent.ts +1374 -0
  5. package/extensions/lovely-agents/bash.ts +599 -0
  6. package/extensions/lovely-agents/child-session.ts +296 -0
  7. package/extensions/lovely-agents/config.ts +221 -0
  8. package/extensions/lovely-agents/coordinator.ts +506 -0
  9. package/extensions/lovely-agents/definitions.ts +380 -0
  10. package/extensions/lovely-agents/index.ts +400 -0
  11. package/extensions/lovely-agents/lifecycle.ts +251 -0
  12. package/extensions/lovely-agents/management.ts +638 -0
  13. package/extensions/lovely-agents/notifications.ts +220 -0
  14. package/extensions/lovely-agents/provider-limits.ts +13 -0
  15. package/extensions/lovely-agents/rendering.ts +90 -0
  16. package/extensions/lovely-agents/state.ts +1179 -0
  17. package/extensions/lovely-agents/task-panel.ts +192 -0
  18. package/extensions/lovely-agents/tools.ts +635 -0
  19. package/extensions/lovely-agents/updates.ts +45 -0
  20. package/node_modules/@xl0/pi-lovely-config/CHANGELOG.md +79 -0
  21. package/node_modules/@xl0/pi-lovely-config/LICENSE +21 -0
  22. package/node_modules/@xl0/pi-lovely-config/README.md +200 -0
  23. package/node_modules/@xl0/pi-lovely-config/package.json +59 -0
  24. package/node_modules/@xl0/pi-lovely-config/src/config.ts +399 -0
  25. package/node_modules/@xl0/pi-lovely-config/src/index.ts +3 -0
  26. package/node_modules/@xl0/pi-lovely-config/src/ui.ts +786 -0
  27. package/package.json +68 -0
  28. package/skills/agent/SKILL.md +21 -0
  29. package/skills/agent-creator/SKILL.md +35 -0
@@ -0,0 +1,296 @@
1
+ import { lstat, open } from "node:fs/promises"
2
+ import {
3
+ type AgentSession,
4
+ type BuildSystemPromptOptions,
5
+ createAgentSession,
6
+ DefaultResourceLoader,
7
+ getAgentDir,
8
+ type LoadExtensionsResult,
9
+ type PromptOptions,
10
+ type ScopedModel,
11
+ SessionManager,
12
+ SettingsManager,
13
+ type Skill
14
+ } from "@earendil-works/pi-coding-agent"
15
+ import { MODEL_ALIASES, type ModelAliasChoice } from "./config.js"
16
+ import { getAgentCoordinator } from "./coordinator.js"
17
+ import type { AgentDefinition, AgentThinkingLevel } from "./definitions.js"
18
+ import type { TaskStoragePaths } from "./state.js"
19
+
20
+ const PROMPT_EXTENSION_PATH = "<inline:lovely-agent-prompt>"
21
+ const CREATION_TOOL_NAMES = new Set(["agent"])
22
+
23
+ export type ChildSessionSelection = {
24
+ model: ScopedModel["model"]
25
+ thinking: AgentThinkingLevel
26
+ }
27
+
28
+ export type ChildToolPolicy = {
29
+ tools?: string[]
30
+ excludeTools: string[]
31
+ depth: number
32
+ allowAgents: boolean
33
+ }
34
+
35
+ export type CreateChildSessionOptions = {
36
+ cwd: string
37
+ paths: TaskStoragePaths
38
+ definition: AgentDefinition
39
+ selection: ChildSessionSelection
40
+ scopedModels: readonly ScopedModel[]
41
+ parentDepth: number
42
+ maximumDepth: number
43
+ allowAgents: boolean
44
+ projectTrusted: boolean
45
+ expectedSessionId?: string
46
+ agentDir?: string
47
+ }
48
+
49
+ export type ChildSessionHandle = {
50
+ session: AgentSession
51
+ extensionsResult: LoadExtensionsResult
52
+ depth: number
53
+ allowAgents: boolean
54
+ dispose(): void
55
+ }
56
+
57
+ export function childPromptOptions(expandPromptTemplates: boolean): PromptOptions {
58
+ return { expandPromptTemplates }
59
+ }
60
+
61
+ export function resolveChildSessionSelection(options: {
62
+ callModel?: string
63
+ callThinking?: AgentThinkingLevel
64
+ definition: AgentDefinition
65
+ configuredModels: readonly ScopedModel[]
66
+ aliases?: readonly ModelAliasChoice[]
67
+ availableModels: readonly ScopedModel["model"][]
68
+ parentModel: ScopedModel["model"] | undefined
69
+ parentThinking: AgentThinkingLevel
70
+ }): ChildSessionSelection {
71
+ let model: ScopedModel["model"] | undefined
72
+ const reference = options.callModel ?? options.definition.model
73
+ const alias = options.aliases?.find(alias => alias.name === reference)
74
+ if (reference && Object.hasOwn(MODEL_ALIASES, reference) && !alias) {
75
+ throw new Error(`Model alias "${reference}" is not configured or its model is unavailable`)
76
+ }
77
+ if (alias) {
78
+ model = alias.model
79
+ } else if (options.callModel) {
80
+ model = options.configuredModels.find(choice => modelId(choice.model) === options.callModel)?.model
81
+ if (!model) throw new Error(`Model "${options.callModel}" is not an available configured choice`)
82
+ } else if (options.definition.model) {
83
+ model = options.availableModels.find(candidate => modelId(candidate) === options.definition.model)
84
+ if (!model) throw new Error(`Agent Definition model "${options.definition.model}" is not authenticated`)
85
+ } else {
86
+ model = options.parentModel
87
+ if (!model) throw new Error("No parent model is available")
88
+ }
89
+ return {
90
+ model,
91
+ thinking:
92
+ options.callThinking ??
93
+ (options.callModel ? alias?.thinkingLevel : undefined) ??
94
+ options.definition.thinking ??
95
+ alias?.thinkingLevel ??
96
+ options.parentThinking
97
+ }
98
+ }
99
+
100
+ export function resolveChildToolPolicy(options: {
101
+ definitionTools?: readonly string[]
102
+ parentDepth: number
103
+ maximumDepth: number
104
+ allowAgents: boolean
105
+ }): ChildToolPolicy {
106
+ if (!Number.isSafeInteger(options.parentDepth) || options.parentDepth < 0)
107
+ throw new Error("Parent depth must be a nonnegative safe integer")
108
+ if (!Number.isSafeInteger(options.maximumDepth) || options.maximumDepth < 0) {
109
+ throw new Error("Maximum depth must be a nonnegative safe integer")
110
+ }
111
+ const depth = options.parentDepth + 1
112
+ if (depth > options.maximumDepth) throw new Error(`Agent depth ${depth} exceeds configured maximum ${options.maximumDepth}`)
113
+ const allowAgents = options.allowAgents && depth < options.maximumDepth
114
+ const excludeTools = allowAgents ? [] : [...CREATION_TOOL_NAMES]
115
+ const tools = options.definitionTools?.filter(name => allowAgents || !CREATION_TOOL_NAMES.has(name))
116
+ return { ...(tools ? { tools } : {}), excludeTools, depth, allowAgents }
117
+ }
118
+
119
+ export async function createChildSession(options: CreateChildSessionOptions): Promise<ChildSessionHandle> {
120
+ const policy = resolveChildToolPolicy({
121
+ ...(options.definition.tools ? { definitionTools: options.definition.tools } : {}),
122
+ parentDepth: options.parentDepth,
123
+ maximumDepth: options.maximumDepth,
124
+ allowAgents: options.allowAgents
125
+ })
126
+ const agentDir = options.agentDir ?? getAgentDir()
127
+ const settingsManager = SettingsManager.create(options.cwd, agentDir)
128
+ settingsManager.setProjectTrusted(options.projectTrusted)
129
+ const resourceLoader = new DefaultResourceLoader({
130
+ cwd: options.cwd,
131
+ agentDir,
132
+ settingsManager,
133
+ systemPromptOverride: () => options.definition.systemPrompt,
134
+ ...(options.definition.excludeAgentsMd ? { agentsFilesOverride: () => ({ agentsFiles: [] }) } : {}),
135
+ extensionFactories: [
136
+ {
137
+ name: "lovely-agent-prompt",
138
+ hidden: true,
139
+ factory(pi) {
140
+ pi.on("before_agent_start", event => ({
141
+ systemPrompt: buildDefinitionSystemPrompt(event.systemPromptOptions)
142
+ }))
143
+ }
144
+ }
145
+ ],
146
+ extensionsOverride: base => {
147
+ const composer = base.extensions.find(extension => extension.path === PROMPT_EXTENSION_PATH)
148
+ if (!composer) return base
149
+ return { ...base, extensions: [composer, ...base.extensions.filter(extension => extension !== composer)] }
150
+ }
151
+ })
152
+ await resourceLoader.reload()
153
+ if (!resourceLoader.getExtensions().extensions.some(extension => extension.path === PROMPT_EXTENSION_PATH)) {
154
+ throw new Error("Could not load the Lovely Agents prompt composer")
155
+ }
156
+
157
+ await reserveSessionFile(options.paths.session)
158
+ const sessionManager = SessionManager.open(options.paths.session, options.paths.taskDirectory, options.cwd)
159
+ const result = await createAgentSession({
160
+ cwd: options.cwd,
161
+ agentDir,
162
+ model: options.selection.model,
163
+ thinkingLevel: options.selection.thinking,
164
+ scopedModels: [...options.scopedModels],
165
+ ...(policy.tools ? { tools: policy.tools } : {}),
166
+ excludeTools: policy.excludeTools,
167
+ resourceLoader,
168
+ sessionManager,
169
+ settingsManager
170
+ })
171
+ if (options.expectedSessionId && result.session.sessionId !== options.expectedSessionId) {
172
+ result.session.dispose()
173
+ throw new Error(`Child session identity mismatch: expected ${options.expectedSessionId}, found ${result.session.sessionId}`)
174
+ }
175
+
176
+ const lifetime = new AbortController()
177
+ const unbindContext = getAgentCoordinator().bindSessionContext(result.session.sessionId, {
178
+ depth: policy.depth,
179
+ allowAgents: policy.allowAgents,
180
+ disposeSignal: lifetime.signal
181
+ })
182
+ try {
183
+ await result.session.bindExtensions({ mode: "print" })
184
+ } catch (error) {
185
+ lifetime.abort()
186
+ unbindContext()
187
+ result.session.dispose()
188
+ throw error
189
+ }
190
+ let disposed = false
191
+ return {
192
+ session: result.session,
193
+ extensionsResult: result.extensionsResult,
194
+ depth: policy.depth,
195
+ allowAgents: policy.allowAgents,
196
+ dispose() {
197
+ if (disposed) return
198
+ disposed = true
199
+ lifetime.abort()
200
+ unbindContext()
201
+ result.session.dispose()
202
+ }
203
+ }
204
+ }
205
+
206
+ export function buildDefinitionSystemPrompt(options: BuildSystemPromptOptions): string {
207
+ const body = options.customPrompt?.trim()
208
+ if (!body) throw new Error("Agent Definition body is empty")
209
+ const tools = options.selectedTools ?? []
210
+ const visibleTools = tools.filter(name => options.toolSnippets?.[name])
211
+ const toolList = visibleTools.length > 0 ? visibleTools.map(name => `- ${name}: ${options.toolSnippets?.[name]}`).join("\n") : "(none)"
212
+ const guidelines: string[] = []
213
+ const seen = new Set<string>()
214
+ const addGuideline = (value: string) => {
215
+ const guideline = value.trim()
216
+ if (!guideline || seen.has(guideline)) return
217
+ seen.add(guideline)
218
+ guidelines.push(guideline)
219
+ }
220
+ const hasBash = tools.includes("bash")
221
+ const hasPowerShell = tools.includes("powershell")
222
+ if ((hasBash || hasPowerShell) && !tools.some(name => name === "grep" || name === "find" || name === "ls")) {
223
+ addGuideline(
224
+ hasBash && hasPowerShell
225
+ ? "Use bash or PowerShell for file operations like listing, searching, and finding files"
226
+ : hasPowerShell
227
+ ? "Use PowerShell for file operations like listing, searching, and finding files"
228
+ : "Use bash for file operations like ls, rg, find"
229
+ )
230
+ }
231
+ for (const guideline of options.promptGuidelines ?? []) addGuideline(guideline)
232
+ addGuideline("Be concise in your responses")
233
+ addGuideline("Show file paths clearly when working with files")
234
+
235
+ let prompt = `${body}\n\nAvailable tools:\n${toolList}\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.\n\nGuidelines:\n${guidelines.map(value => `- ${value}`).join("\n")}`
236
+ if (options.appendSystemPrompt) prompt += `\n\n${options.appendSystemPrompt}`
237
+ if (options.contextFiles && options.contextFiles.length > 0) {
238
+ prompt += "\n\n<project_context>\n\nProject-specific instructions and guidelines:\n\n"
239
+ for (const file of options.contextFiles) {
240
+ prompt += `<project_instructions path="${file.path}">\n${file.content}\n</project_instructions>\n\n`
241
+ }
242
+ prompt += "</project_context>\n"
243
+ }
244
+ const skillReadTool = (["read", "bash"] as const).find(name => tools.includes(name))
245
+ if (skillReadTool && options.skills && options.skills.length > 0) {
246
+ prompt += formatSkillsForChild(options.skills, skillReadTool)
247
+ }
248
+ prompt += `\nCurrent working directory: ${options.cwd.replace(/\\/g, "/")}\n`
249
+ return prompt
250
+ }
251
+
252
+ function modelId(model: ScopedModel["model"]): string {
253
+ return `${model.provider}/${model.id}`
254
+ }
255
+
256
+ function formatSkillsForChild(skills: Skill[], fileReadTool: "read" | "bash"): string {
257
+ const visible = skills.filter(skill => !skill.disableModelInvocation)
258
+ if (visible.length === 0) return ""
259
+ const lines = [
260
+ "",
261
+ "",
262
+ "The following skills provide specialized instructions for specific tasks.",
263
+ `Use the ${fileReadTool} tool to load a skill's file when the task matches its description.`,
264
+ "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
265
+ "",
266
+ "<available_skills>"
267
+ ]
268
+ for (const skill of visible) {
269
+ lines.push(" <skill>")
270
+ lines.push(` <name>${escapeXml(skill.name)}</name>`)
271
+ lines.push(` <description>${escapeXml(skill.description)}</description>`)
272
+ lines.push(` <location>${escapeXml(skill.filePath)}</location>`)
273
+ lines.push(" </skill>")
274
+ }
275
+ lines.push("</available_skills>")
276
+ return lines.join("\n")
277
+ }
278
+
279
+ function escapeXml(value: string): string {
280
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;")
281
+ }
282
+
283
+ async function reserveSessionFile(path: string): Promise<void> {
284
+ try {
285
+ const file = await open(path, "wx", 0o600)
286
+ await file.close()
287
+ } catch (error) {
288
+ if (!hasCode(error, "EEXIST")) throw error
289
+ const stats = await lstat(path)
290
+ if (!stats.isFile() || stats.isSymbolicLink()) throw new Error(`Child session path is not a regular file: ${path}`)
291
+ }
292
+ }
293
+
294
+ function hasCode(error: unknown, code: string): boolean {
295
+ return typeof error === "object" && error !== null && "code" in error && error.code === code
296
+ }
@@ -0,0 +1,221 @@
1
+ import type { ExtensionContext, ScopedModel } from "@earendil-works/pi-coding-agent"
2
+ import { type ConfigFromSchema, defineScopedConfig, field, type ScopedConfig } from "@xl0/pi-lovely-config"
3
+
4
+ const NO_MODELS = "(no authenticated models)"
5
+ const DISABLED_MODEL = "disabled"
6
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const
7
+ export const MODEL_ALIASES = {
8
+ fast: "Cheap, low-latency model for straightforward tasks.",
9
+ smart: "Most capable model for difficult reasoning and complex work.",
10
+ workhorse: "Balanced cost and capability for routine coding and research."
11
+ } as const
12
+ export type ModelAliasName = keyof typeof MODEL_ALIASES
13
+ /** A user-selected preset, resolved to an authenticated model before creation. */
14
+ export type ModelAliasChoice = {
15
+ name: ModelAliasName
16
+ model: ScopedModel["model"]
17
+ thinkingLevel: NonNullable<ScopedModel["thinkingLevel"]>
18
+ }
19
+ type ModelConfigContext = Pick<ExtensionContext, "model" | "modelRegistry">
20
+
21
+ function createConfigSchema(ctx?: ModelConfigContext) {
22
+ const availableModels = ctx?.modelRegistry.getAvailable() ?? []
23
+ const modelIds = [...new Set(availableModels.map(model => `${model.provider}/${model.id}`))]
24
+ const modelValues = (modelIds.length > 0 ? modelIds : [NO_MODELS]) as [string, ...string[]]
25
+ const valueDescriptions = Object.fromEntries(availableModels.map(model => [`${model.provider}/${model.id}`, model.name || model.id]))
26
+ const aliasModel = (name: ModelAliasName) =>
27
+ field.enum([DISABLED_MODEL, ...modelIds] as [string, ...string[]], DISABLED_MODEL, {
28
+ label: `${name} model`,
29
+ description: MODEL_ALIASES[name],
30
+ search: true,
31
+ valueDescriptions: { [DISABLED_MODEL]: "Do not expose this alias", ...valueDescriptions }
32
+ })
33
+ const aliasThinking = (name: ModelAliasName, level: ModelAliasChoice["thinkingLevel"]) =>
34
+ field.enum(THINKING_LEVELS, level, {
35
+ label: `${name} thinking`,
36
+ description: "Preset effort; an explicit thinking argument overrides it.",
37
+ depth: 1,
38
+ visibleWhen: ctx => ctx.get(`${name}Model`) !== DISABLED_MODEL
39
+ })
40
+ return {
41
+ backgroundAgents: field.boolean(true, {
42
+ label: "Background agents",
43
+ description: "Allow detached agents and asynchronous Follow-ups. Off keeps agents in the foreground."
44
+ }),
45
+ backgroundBash: field.boolean(true, {
46
+ label: "Background Bash",
47
+ description: "Run Bash commands as managed background tasks."
48
+ }),
49
+ models: field.multiEnum(modelValues, [], {
50
+ label: "Models",
51
+ description: "Additional model IDs available for agent selection. Empty includes the parent. Alias targets are always included.",
52
+ valueDescriptions
53
+ }),
54
+ fastModel: aliasModel("fast"),
55
+ fastThinking: aliasThinking("fast", "low"),
56
+ smartModel: aliasModel("smart"),
57
+ smartThinking: aliasThinking("smart", "high"),
58
+ workhorseModel: aliasModel("workhorse"),
59
+ workhorseThinking: aliasThinking("workhorse", "medium"),
60
+ maxConcurrency: field.number(4, {
61
+ label: "Max concurrency",
62
+ description: "Maximum agent runs executing in this process.",
63
+ min: 1,
64
+ step: 1
65
+ }),
66
+ maxBashConcurrency: field.number(4, {
67
+ label: "Bash concurrency",
68
+ description: "Maximum background Bash processes, separate from agent permits.",
69
+ min: 1,
70
+ step: 1,
71
+ visibleWhen: ctx => ctx.get("backgroundBash") === true
72
+ }),
73
+ maxDepth: field.number(2, {
74
+ label: "Max depth",
75
+ description: "Maximum agent delegation depth. The root session is depth 0.",
76
+ min: 0,
77
+ step: 1
78
+ }),
79
+ waitMs: field.number(30_000, {
80
+ label: "Initial wait (ms)",
81
+ description: "How long agent creation waits before detaching.",
82
+ min: 0,
83
+ step: 1000,
84
+ visibleWhen: ctx => ctx.get("backgroundAgents") === true
85
+ }),
86
+ expandPromptTemplates: field.boolean(false, {
87
+ label: "Expand prompt templates",
88
+ description: "Interpret child skill commands, prompt templates, and extension commands."
89
+ })
90
+ } as const
91
+ }
92
+
93
+ const configSchema = createConfigSchema()
94
+ type RawAgentsConfig = ConfigFromSchema<typeof configSchema>
95
+ type ConfigScope = "user" | "workspace"
96
+
97
+ export type AgentsConfig = RawAgentsConfig
98
+
99
+ export type AgentsConfigWarning = {
100
+ scope: ConfigScope
101
+ path: string
102
+ key?: string
103
+ message: string
104
+ }
105
+
106
+ export type ModelChoice = ScopedModel
107
+
108
+ export type ModelChoiceDiagnostic = {
109
+ type: "warning"
110
+ code: "no-match"
111
+ message: string
112
+ pattern: string
113
+ }
114
+
115
+ export const defaultAgentsConfig: AgentsConfig = {
116
+ ...createAgentsConfigSpec().defaults
117
+ }
118
+
119
+ export function createAgentsConfigSpec(ctx?: ModelConfigContext): ScopedConfig<RawAgentsConfig> {
120
+ return defineScopedConfig({
121
+ fileName: "xl0-pi-lovely-agents.json",
122
+ schema: createConfigSchema(ctx)
123
+ }) as ScopedConfig<RawAgentsConfig>
124
+ }
125
+
126
+ export function resolveAgentsConfig(config: ScopedConfig<RawAgentsConfig>): {
127
+ value: AgentsConfig
128
+ warnings: AgentsConfigWarning[]
129
+ } {
130
+ const scoped = {
131
+ user: { ...config.scoped.user },
132
+ workspace: { ...config.scoped.workspace }
133
+ }
134
+ const warnings: AgentsConfigWarning[] = [...config.warnings]
135
+
136
+ for (const scope of config.scopes) {
137
+ for (const key of ["maxConcurrency", "maxBashConcurrency", "maxDepth", "waitMs"] as const) {
138
+ const value = scoped[scope][key]
139
+ if (typeof value !== "number" || Number.isInteger(value)) continue
140
+ delete scoped[scope][key]
141
+ warnings.push({
142
+ scope,
143
+ path: config.path(scope),
144
+ key,
145
+ message: `/${key} must be an integer; value is ignored while resolving`
146
+ })
147
+ }
148
+ }
149
+
150
+ return { value: config.resolve(scoped), warnings }
151
+ }
152
+
153
+ export function loadAgentsConfig(
154
+ cwd: string,
155
+ ctx?: ModelConfigContext
156
+ ): {
157
+ value: AgentsConfig
158
+ warnings: AgentsConfigWarning[]
159
+ } {
160
+ return resolveAgentsConfig(createAgentsConfigSpec(ctx).load(cwd))
161
+ }
162
+
163
+ export function resolveModelChoices(options: {
164
+ selections: readonly string[]
165
+ availableModels: readonly ScopedModel["model"][]
166
+ parentModel: ScopedModel["model"] | undefined
167
+ }): { models: ModelChoice[]; diagnostics: ModelChoiceDiagnostic[] } {
168
+ if (options.selections.length === 0) {
169
+ return options.parentModel
170
+ ? { models: [{ model: options.parentModel }], diagnostics: [] }
171
+ : {
172
+ models: [],
173
+ diagnostics: [{ type: "warning", code: "no-match", message: "No current parent model is available", pattern: "" }]
174
+ }
175
+ }
176
+
177
+ const models: ModelChoice[] = []
178
+ const diagnostics: ModelChoiceDiagnostic[] = []
179
+ for (const selection of options.selections) {
180
+ const model = options.availableModels.find(model => `${model.provider}/${model.id}` === selection)
181
+ if (model) models.push({ model })
182
+ else {
183
+ diagnostics.push({
184
+ type: "warning",
185
+ code: "no-match",
186
+ message: `Configured model "${selection}" is not available`,
187
+ pattern: selection
188
+ })
189
+ }
190
+ }
191
+ return { models, diagnostics }
192
+ }
193
+
194
+ export function resolveConfiguredModels(config: AgentsConfig, ctx: ExtensionContext) {
195
+ const availableModels = ctx.modelRegistry.getAvailable()
196
+ const resolved = resolveModelChoices({
197
+ selections: config.models,
198
+ availableModels,
199
+ parentModel: ctx.model
200
+ })
201
+ const aliases: ModelAliasChoice[] = []
202
+ for (const name of Object.keys(MODEL_ALIASES) as ModelAliasName[]) {
203
+ const target = config[`${name}Model`]
204
+ if (target === DISABLED_MODEL) continue
205
+ const model = availableModels.find(model => `${model.provider}/${model.id}` === target)
206
+ if (!model) {
207
+ resolved.diagnostics.push({
208
+ type: "warning",
209
+ code: "no-match",
210
+ pattern: target,
211
+ message: `Model alias "${name}" targets unavailable model "${target}"`
212
+ })
213
+ continue
214
+ }
215
+ aliases.push({ name, model, thinkingLevel: config[`${name}Thinking`] })
216
+ if (!resolved.models.some(choice => choice.model.provider === model.provider && choice.model.id === model.id)) {
217
+ resolved.models.push({ model })
218
+ }
219
+ }
220
+ return { ...resolved, aliases }
221
+ }