Reviewed issue #844 and related comments.
UnderstandExamined all related code files in full.
UnderstandTraced code paths for model selection and session handling.
UnderstandProposed a fix approach based on root cause analysis.
PlanAnalyze GitHub issue(s): https://github.com/badlogic/pi-mono/issues/844
For each issue:
Read the issue in full, including all comments and linked issues/PRs.
For bugs:
For feature requests:
Do NOT implement unless explicitly asked. Analyze and propose only.
Preparing to view GitHub issue
>gh issue view 844 --json title,body,comments,labels,state{"body":"### What happened?\n\nWhen selecting some models in /scoped-models and persisting the settings, /model selection switches to \"Showing models from --models scope\" and there's seemingly no way to access the full model index again, unless you clear your scoped models.\n\n### Steps to reproduce\n\n1. Go to /scoped-models and press Ctrl+X to make sure to clear any previous preferences.\n2. Select some models in /scoped-models\n3. Press Ctrl+X to persist settings across sessions\n4. Go to /model\n5. See only your selected \"scoped model\" and a hint \"Showing models from --models scope\", without any way to see the full list of models or reset the view\n6. Go to /scoped-models and clear them\n7. Go to /model -- works normally again\n\n### Expected behavior\n\nI think /model should always show all the models available to you, without filtering them by what you have selected in /scoped-models. Or have a switch between these modes of display for any scoped-model maniacs out there.\n\n### Version\n\n0.49.1","comments":[],"labels":[{"id":"LA_kwDOPbFNk88AAAACHWTBxw","name":"bug","description":"Something isn't working","color":"d73a4a"}],"state":"OPEN","title":"If you have saved models in /scoped-models, all other models in /model disappear"}Starting full related code exploration
I'm preparing to search for files related to scoped models and the model to understand the bug context fully.
>lsAGENTS.md
biome.json
CONTRIBUTING.md
LICENSE
node_modules
package-lock.json
package.json
packages
pi-mono.code-workspace
pi-test.sh
README.md
scripts
test.sh
tsconfig.base.json
tsconfig.jsonChoosing search tool
Considering whether to use grep or ripgrep for locating the needed item.
>rg -n "scoped-models|scoped models|scopedModels|scoped_model" packagespackages/coding-agent/test/fixtures/before-compaction.jsonl:792:{"type":"message","timestamp":"2025-12-09T00:19:02.898Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to:\n1. Finish the refactoring and commit\n2. Remove old files (main.ts, cli.ts, tui-renderer.ts)\n3. Add a code map to AGENTS.md in the root dir for coding-agent\n4. Add a DEVELOPMENT.md in the coding-agent package that describes the setup, referenced by README.md\n\nLet me continue with the refactoring first - I still need to create model-resolver.ts and update main-new.ts.","thinkingSignature":"Eq0ECkYIChgCKkDMdSdHeReC0tnFQZoczAUPw2WDGSf27PiGG+peWNMxOXI7cPzUiVPW0ts8fFH2UNyNSXIK+ll18NwfNewphFtXEgwwKq+bmf9Qcuw1bIQaDGC0f1fSXxkV3QB0qCIw1/1pD7KK/LKRdMJ9nUuwir9z8Y3AvNg1zNZpQxrZhS7KzmcL4IuXwMQ1fDV6GexEKpQDoBZkHE6WMVhmcodjhbskIh0ZX484Rv2irz9ZOsYGePNQoJAo3DJuB1ChaICRqzyrLopo21V8SMkOAoTl40RtMmx3vAI6+7qiRVnSpNjtZvrCD8hlOPzM1BSrujU5rwWZ49dkNJZrHI6P+PMPQLvXO6o5AYPKDKbGvbmg+DOFRlmpGgqi6wojTWs4bScydM/IgoCkp0Bs3Jsjlzk0oQ3ZGOPNCb1IZvFK6HDZn1Jy/y4+pKnoXfhwnDmt1/A54U3VJOB4rZ0XLztFYiVwBErRdIOqwMMyu8jakB1+EysXTRVnN1lMrz3lSNE73fF3qswLdl59RswJyJeRhjLoUCX1DBCZ/Anzhk4KgRAVB3B/X3XvIVW6m+gk+P2BYCFp/Zlao75UBLn3P4m2JJcyv21ri2pXw3uq97Y2RWi0pXbcL8SE2X8osQ01oiQGQZkc1PKCz133dnIJNmEwA0rjDnWMdl0iVR3lztZTXWuXqgtAL9fPibwrQarI2RNYwkpuoE4/9X8cOBPp3jVCMkcjhI4frzjULeIYAQ=="},{"type":"toolCall","id":"toolu_01BEf6CjuvnF8TjjP8QrWPuh","name":"write","arguments":{"path":"/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/model-resolver.ts","content":"/**\n * Model resolution, scoping, and initial selection\n */\n\nimport type { ThinkingLevel } from \"@mariozechner/pi-agent-core\";\nimport type { Api, KnownProvider, Model } from \"@mariozechner/pi-ai\";\nimport chalk from \"chalk\";\nimport { isValidThinkingLevel } from \"../cli/args.js\";\nimport { findModel, getApiKeyForModel, getAvailableModels } from \"./model-config.js\";\nimport type { SettingsManager } from \"./settings-manager.js\";\n\n/** Default model IDs for each known provider */\nexport const defaultModelPerProvider: Record<KnownProvider, string> = {\n\tanthropic: \"claude-sonnet-4-5\",\n\topenai: \"gpt-5.1-codex\",\n\tgoogle: \"gemini-2.5-pro\",\n\topenrouter: \"openai/gpt-5.1-codex\",\n\txai: \"grok-4-fast-non-reasoning\",\n\tgroq: \"openai/gpt-oss-120b\",\n\tcerebras: \"zai-glm-4.6\",\n\tzai: \"glm-4.6\",\n};\n\nexport interface ScopedModel {\n\tmodel: Model<Api>;\n\tthinkingLevel: ThinkingLevel;\n}\n\n/**\n * Resolve model patterns to actual Model objects with optional thinking levels\n * Format: \"pattern:level\" where :level is optional\n * For each pattern, finds all matching models and picks the best version:\n * 1. Prefer alias (e.g., claude-sonnet-4-5) over dated versions (claude-sonnet-4-5-20250929)\n * 2. If no alias, pick the latest dated version\n */\nexport async function resolveModelScope(patterns: string[]): Promise<ScopedModel[]> {\n\tconst { models: availableModels, error } = await getAvailableModels();\n\n\tif (error) {\n\t\tconsole.warn(chalk.yellow(`Warning: Error loading models: ${error}`));\n\t\treturn [];\n\t}\n\n\tconst scopedModels: ScopedModel[] = [];\n\n\tfor (const pattern of patterns) {\n\t\t// Parse pattern:level format\n\t\tconst parts = pattern.split(\":\");\n\t\tconst modelPattern = parts[0];\n\t\tlet thinkingLevel: ThinkingLevel = \"off\";\n\n\t\tif (parts.length > 1) {\n\t\t\tconst level = parts[1];\n\t\t\tif (isValidThinkingLevel(level)) {\n\t\t\t\tthinkingLevel = level;\n\t\t\t} else {\n\t\t\t\tconsole.warn(\n\t\t\t\t\tchalk.yellow(`Warning: Invalid thinking level \"${level}\" in pattern \"${pattern}\". Using \"off\" instead.`),\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\t// Check for provider/modelId format (provider is everything before the first /)\n\t\tconst slashIndex = modelPattern.indexOf(\"/\");\n\t\tif (slashIndex !== -1) {\n\t\t\tconst provider = modelPattern.substring(0, slashIndex);\n\t\t\tconst modelId = modelPattern.substring(slashIndex + 1);\n\t\t\tconst providerMatch = availableModels.find(\n\t\t\t\t(m) => m.provider.toLowerCase() === provider.toLowerCase() && m.id.toLowerCase() === modelId.toLowerCase(),\n\t\t\t);\n\t\t\tif (providerMatch) {\n\t\t\t\tif (\n\t\t\t\t\t!scopedModels.find(\n\t\t\t\t\t\t(sm) => sm.model.id === providerMatch.id && sm.model.provider === providerMatch.provider,\n\t\t\t\t\t)\n\t\t\t\t) {\n\t\t\t\t\tscopedModels.push({ model: providerMatch, thinkingLevel });\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\t// No exact provider/model match - fall through to other matching\n\t\t}\n\n\t\t// Check for exact ID match (case-insensitive)\n\t\tconst exactMatch = availableModels.find((m) => m.id.toLowerCase() === modelPattern.toLowerCase());\n\t\tif (exactMatch) {\n\t\t\t// Exact match found - use it directly\n\t\t\tif (!scopedModels.find((sm) => sm.model.id === exactMatch.id && sm.model.provider === exactMatch.provider)) {\n\t\t\t\tscopedModels.push({ model: exactMatch, thinkingLevel });\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\t// No exact match - fall back to partial matching\n\t\tconst matches = availableModels.filter(\n\t\t\t(m) =>\n\t\t\t\tm.id.toLowerCase().includes(modelPattern.toLowerCase()) ||\n\t\t\t\tm.name?.toLowerCase().includes(modelPattern.toLowerCase()),\n\t\t);\n\n\t\tif (matches.length === 0) {\n\t\t\tconsole.warn(chalk.yellow(`Warning: No models match pattern \"${modelPattern}\"`));\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Helper to check if a model ID looks like an alias (no date suffix)\n\t\t// Dates are typically in format: -20241022 or -20250929\n\t\tconst isAlias = (id: string): boolean => {\n\t\t\t// Check if ID ends with -latest\n\t\t\tif (id.endsWith(\"-latest\")) return true;\n\n\t\t\t// Check if ID ends with a date pattern (-YYYYMMDD)\n\t\t\tconst datePattern = /-\\d{8}$/;\n\t\t\treturn !datePattern.test(id);\n\t\t};\n\n\t\t// Separate into aliases and dated versions\n\t\tconst aliases = matches.filter((m) => isAlias(m.id));\n\t\tconst datedVersions = matches.filter((m) => !isAlias(m.id));\n\n\t\tlet bestMatch: Model<Api>;\n\n\t\tif (aliases.length > 0) {\n\t\t\t// Prefer alias - if multiple aliases, pick the one that sorts highest\n\t\t\taliases.sort((a, b) => b.id.localeCompare(a.id));\n\t\t\tbestMatch = aliases[0];\n\t\t} else {\n\t\t\t// No alias found, pick latest dated version\n\t\t\tdatedVersions.sort((a, b) => b.id.localeCompare(a.id));\n\t\t\tbestMatch = datedVersions[0];\n\t\t}\n\n\t\t// Avoid duplicates\n\t\tif (!scopedModels.find((sm) => sm.model.id === bestMatch.id && sm.model.provider === bestMatch.provider)) {\n\t\t\tscopedModels.push({ model: bestMatch, thinkingLevel });\n\t\t}\n\t}\n\n\treturn scopedModels;\n}\n\nexport interface InitialModelResult {\n\tmodel: Model<Api> | null;\n\tthinkingLevel: ThinkingLevel;\n\tfallbackMessage: string | null;\n}\n\n/**\n * Find the initial model to use based on priority:\n * 1. CLI args (provider + model)\n * 2. First model from scoped models (if not continuing/resuming)\n * 3. Restored from session (if continuing/resuming)\n * 4. Saved default from settings\n * 5. First available model with valid API key\n */\nexport async function findInitialModel(options: {\n\tcliProvider?: string;\n\tcliModel?: string;\n\tscopedModels: ScopedModel[];\n\tisContinuing: boolean;\n\tsettingsManager: SettingsManager;\n}): Promise<InitialModelResult> {\n\tconst { cliProvider, cliModel, scopedModels, isContinuing, settingsManager } = options;\n\n\tlet model: Model<Api> | null = null;\n\tlet thinkingLevel: ThinkingLevel = \"off\";\n\n\t// 1. CLI args take priority\n\tif (cliProvider && cliModel) {\n\t\tconst { model: found, error } = findModel(cliProvider, cliModel);\n\t\tif (error) {\n\t\t\tconsole.error(chalk.red(error));\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tif (!found) {\n\t\t\tconsole.error(chalk.red(`Model ${cliProvider}/${cliModel} not found`));\n\t\t\tprocess.exit(1);\n\t\t}\n\t\treturn { model: found, thinkingLevel: \"off\", fallbackMessage: null };\n\t}\n\n\t// 2. Use first model from scoped models (skip if continuing/resuming)\n\tif (scopedModels.length > 0 && !isContinuing) {\n\t\treturn {\n\t\t\tmodel: scopedModels[0].model,\n\t\t\tthinkingLevel: scopedModels[0].thinkingLevel,\n\t\t\tfallbackMessage: null,\n\t\t};\n\t}\n\n\t// 3. Try saved default from settings\n\tconst defaultProvider = settingsManager.getDefaultProvider();\n\tconst defaultModelId = settingsManager.getDefaultModel();\n\tif (defaultProvider && defaultModelId) {\n\t\tconst { model: found, error } = findModel(defaultProvider, defaultModelId);\n\t\tif (error) {\n\t\t\tconsole.error(chalk.red(error));\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tif (found) {\n\t\t\tmodel = found;\n\t\t\t// Also load saved thinking level\n\t\t\tconst savedThinking = settingsManager.getDefaultThinkingLevel();\n\t\t\tif (savedThinking) {\n\t\t\t\tthinkingLevel = savedThinking;\n\t\t\t}\n\t\t\treturn { model, thinkingLevel, fallbackMessage: null };\n\t\t}\n\t}\n\n\t// 4. Try first available model with valid API key\n\tconst { models: availableModels, error } = await getAvailableModels();\n\n\tif (error) {\n\t\tconsole.error(chalk.red(error));\n\t\tprocess.exit(1);\n\t}\n\n\tif (availableModels.length > 0) {\n\t\t// Try to find a default model from known providers\n\t\tfor (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {\n\t\t\tconst defaultId = defaultModelPerProvider[provider];\n\t\t\tconst match = availableModels.find((m) => m.provider === provider && m.id === defaultId);\n\t\t\tif (match) {\n\t\t\t\treturn { model: match, thinkingLevel: \"off\", fallbackMessage: null };\n\t\t\t}\n\t\t}\n\n\t\t// If no default found, use first available\n\t\treturn { model: availableModels[0], thinkingLevel: \"off\", fallbackMessage: null };\n\t}\n\n\t// 5. No model found\n\treturn { model: null, thinkingLevel: \"off\", fallbackMessage: null };\n}\n\n/**\n * Restore model from session, with fallback to available models\n */\nexport async function restoreModelFromSession(\n\tsavedProvider: string,\n\tsavedModelId: string,\n\tcurrentModel: Model<Api> | null,\n\tshouldPrintMessages: boolean,\n): Promise<{ model: Model<Api> | null; fallbackMessage: string | null }> {\n\tconst { model: restoredModel, error } = findModel(savedProvider, savedModelId);\n\n\tif (error) {\n\t\tconsole.error(chalk.red(error));\n\t\tprocess.exit(1);\n\t}\n\n\t// Check if restored model exists and has a valid API key\n\tconst hasApiKey = restoredModel ? !!(await getApiKeyForModel(restoredModel)) : false;\n\n\tif (restoredModel && hasApiKey) {\n\t\tif (shouldPrintMessages) {\n\t\t\tconsole.log(chalk.dim(`Restored model: ${savedProvider}/${savedModelId}`));\n\t\t}\n\t\treturn { model: restoredModel, fallbackMessage: null };\n\t}\n\n\t// Model not found or no API key - fall back\n\tconst reason = !restoredModel ? \"model no longer exists\" : \"no API key available\";\n\n\tif (shouldPrintMessages) {\n\t\tconsole.error(\n\t\t\tchalk.yellow(`Warning: Could not restore model ${savedProvider}/${savedModelId} (${reason}).`),\n\t\t);\n\t}\n\n\t// If we already have a model, use it as fallback\n\tif (currentModel) {\n\t\tif (shouldPrintMessages) {\n\t\t\tconsole.log(chalk.dim(`Falling back to: ${currentModel.provider}/${currentModel.id}`));\n\t\t}\n\t\treturn {\n\t\t\tmodel: currentModel,\n\t\t\tfallbackMessage: `Could not restore model ${savedProvider}/${savedModelId} (${reason}). Using ${currentModel.provider}/${currentModel.id}.`,\n\t\t};\n\t}\n\n\t// Try to find any available model\n\tconst { models: availableModels, error: availableError } = await getAvailableModels();\n\tif (availableError) {\n\t\tconsole.error(chalk.red(availableError));\n\t\tprocess.exit(1);\n\t}\n\n\tif (availableModels.length > 0) {\n\t\t// Try to find a default model from known providers\n\t\tlet fallbackModel: Model<Api> | null = null;\n\t\tfor (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {\n\t\t\tconst defaultId = defaultModelPerProvider[provider];\n\t\t\tconst match = availableModels.find((m) => m.provider === provider && m.id === defaultId);\n\t\t\tif (match) {\n\t\t\t\tfallbackModel = match;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\t// If no default found, use first available\n\t\tif (!fallbackModel) {\n\t\t\tfallbackModel = availableModels[0];\n\t\t}\n\n\t\tif (shouldPrintMessages) {\n\t\t\tconsole.log(chalk.dim(`Falling back to: ${fallbackModel.provider}/${fallbackModel.id}`));\n\t\t}\n\n\t\treturn {\n\t\t\tmodel: fallbackModel,\n\t\t\tfallbackMessage: `Could not restore model ${savedProvider}/${savedModelId} (${reason}). Using ${fallbackModel.provider}/${fallbackModel.id}.`,\n\t\t};\n\t}\n\n\t// No models available\n\treturn { model: null, fallbackMessage: null };\n}\n"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-5","usage":{"input":10,"output":3523,"cacheRead":113953,"cacheWrite":2596,"totalTokens":120082,"cost":{"input":0.00005,"output":0.088075,"cacheRead":0.0569765,"cacheWrite":0.016225,"total":0.16132649999999998}},"stopReason":"toolUse","timestamp":1765239496723}}
packages/coding-agent/test/fixtures/before-compaction.jsonl:794:{"type":"message","timestamp":"2025-12-09T00:20:02.807Z","message":{"role":"assistant","content":[{"type":"text","text":"Now let me create the updated main-new.ts:"},{"type":"toolCall","id":"toolu_01KBMrVY3SacbGjXpi3iDgMN","name":"write","arguments":{"path":"/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/main-new.ts","content":"/**\n * Main entry point for the coding agent\n */\n\nimport { Agent, type Attachment, ProviderTransport, type ThinkingLevel } from \"@mariozechner/pi-agent-core\";\nimport chalk from \"chalk\";\nimport { AgentSession } from \"./core/agent-session.js\";\nimport { exportFromFile } from \"./core/export-html.js\";\nimport { messageTransformer } from \"./core/messages.js\";\nimport { findModel, getApiKeyForModel, getAvailableModels } from \"./core/model-config.js\";\nimport { type ScopedModel, resolveModelScope, restoreModelFromSession } from \"./core/model-resolver.js\";\nimport { SessionManager } from \"./core/session-manager.js\";\nimport { SettingsManager } from \"./core/settings-manager.js\";\nimport { loadSlashCommands } from \"./core/slash-commands.js\";\nimport { buildSystemPrompt, loadProjectContextFiles } from \"./core/system-prompt.js\";\nimport { allTools, codingTools } from \"./core/tools/index.js\";\nimport { type Args, parseArgs, printHelp } from \"./cli/args.js\";\nimport { processFileArguments } from \"./cli/file-processor.js\";\nimport { selectSession } from \"./cli/session-picker.js\";\nimport { InteractiveMode, runPrintMode, runRpcMode } from \"./modes/index.js\";\nimport { initTheme } from \"./modes/interactive/theme/theme.js\";\nimport { getChangelogPath, getNewEntries, parseChangelog } from \"./utils/changelog.js\";\nimport { getModelsPath, VERSION } from \"./utils/config.js\";\nimport { ensureTool } from \"./utils/tools-manager.js\";\n\n/** Check npm registry for new version (non-blocking) */\nasync function checkForNewVersion(currentVersion: string): Promise<string | null> {\n\ttry {\n\t\tconst response = await fetch(\"https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest\");\n\t\tif (!response.ok) return null;\n\n\t\tconst data = (await response.json()) as { version?: string };\n\t\tconst latestVersion = data.version;\n\n\t\tif (latestVersion && latestVersion !== currentVersion) {\n\t\t\treturn latestVersion;\n\t\t}\n\n\t\treturn null;\n\t} catch {\n\t\t// Silently fail - don't disrupt the user experience\n\t\treturn null;\n\t}\n}\n\n/** Run interactive mode with TUI */\nasync function runInteractiveMode(\n\tsession: AgentSession,\n\tversion: string,\n\tchangelogMarkdown: string | null,\n\tmodelFallbackMessage: string | null,\n\tversionCheckPromise: Promise<string | null>,\n\tinitialMessages: string[],\n\tinitialMessage?: string,\n\tinitialAttachments?: Attachment[],\n\tfdPath: string | null = null,\n): Promise<void> {\n\tconst mode = new InteractiveMode(session, version, changelogMarkdown, fdPath);\n\n\t// Initialize TUI (subscribes to agent events internally)\n\tawait mode.init();\n\n\t// Handle version check result when it completes (don't block)\n\tversionCheckPromise.then((newVersion) => {\n\t\tif (newVersion) {\n\t\t\tmode.showNewVersionNotification(newVersion);\n\t\t}\n\t});\n\n\t// Render any existing messages (from --continue mode)\n\tmode.renderInitialMessages(session.state);\n\n\t// Show model fallback warning at the end of the chat if applicable\n\tif (modelFallbackMessage) {\n\t\tmode.showWarning(modelFallbackMessage);\n\t}\n\n\t// Process initial message with attachments if provided (from @file args)\n\tif (initialMessage) {\n\t\ttry {\n\t\t\tawait session.prompt(initialMessage, { attachments: initialAttachments });\n\t\t} catch (error: unknown) {\n\t\t\tconst errorMessage = error instanceof Error ? error.message : \"Unknown error occurred\";\n\t\t\tmode.showError(errorMessage);\n\t\t}\n\t}\n\n\t// Process remaining initial messages if provided (from CLI args)\n\tfor (const message of initialMessages) {\n\t\ttry {\n\t\t\tawait session.prompt(message);\n\t\t} catch (error: unknown) {\n\t\t\tconst errorMessage = error instanceof Error ? error.message : \"Unknown error occurred\";\n\t\t\tmode.showError(errorMessage);\n\t\t}\n\t}\n\n\t// Interactive loop\n\twhile (true) {\n\t\tconst userInput = await mode.getUserInput();\n\n\t\t// Process the message\n\t\ttry {\n\t\t\tawait session.prompt(userInput);\n\t\t} catch (error: unknown) {\n\t\t\tconst errorMessage = error instanceof Error ? error.message : \"Unknown error occurred\";\n\t\t\tmode.showError(errorMessage);\n\t\t}\n\t}\n}\n\n/** Prepare initial message from @file arguments */\nfunction prepareInitialMessage(parsed: Args): {\n\tinitialMessage?: string;\n\tinitialAttachments?: Attachment[];\n} {\n\tif (parsed.fileArgs.length === 0) {\n\t\treturn {};\n\t}\n\n\tconst { textContent, imageAttachments } = processFileArguments(parsed.fileArgs);\n\n\t// Combine file content with first plain text message (if any)\n\tlet initialMessage: string;\n\tif (parsed.messages.length > 0) {\n\t\tinitialMessage = textContent + parsed.messages[0];\n\t\tparsed.messages.shift(); // Remove first message as it's been combined\n\t} else {\n\t\tinitialMessage = textContent;\n\t}\n\n\treturn {\n\t\tinitialMessage,\n\t\tinitialAttachments: imageAttachments.length > 0 ? imageAttachments : undefined,\n\t};\n}\n\nexport async function main(args: string[]) {\n\tconst parsed = parseArgs(args);\n\n\tif (parsed.help) {\n\t\tprintHelp();\n\t\treturn;\n\t}\n\n\t// Handle --export flag: convert session file to HTML and exit\n\tif (parsed.export) {\n\t\ttry {\n\t\t\tconst outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined;\n\t\t\tconst result = exportFromFile(parsed.export, outputPath);\n\t\t\tconsole.log(`Exported to: ${result}`);\n\t\t\treturn;\n\t\t} catch (error: unknown) {\n\t\t\tconst message = error instanceof Error ? error.message : \"Failed to export session\";\n\t\t\tconsole.error(chalk.red(`Error: ${message}`));\n\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\n\t// Validate: RPC mode doesn't support @file arguments\n\tif (parsed.mode === \"rpc\" && parsed.fileArgs.length > 0) {\n\t\tconsole.error(chalk.red(\"Error: @file arguments are not supported in RPC mode\"));\n\t\tprocess.exit(1);\n\t}\n\n\t// Process @file arguments\n\tconst { initialMessage, initialAttachments } = prepareInitialMessage(parsed);\n\n\t// Initialize theme (before any TUI rendering)\n\tconst settingsManager = new SettingsManager();\n\tconst themeName = settingsManager.getTheme();\n\tinitTheme(themeName);\n\n\t// Setup session manager\n\tconst sessionManager = new SessionManager(parsed.continue && !parsed.resume, parsed.session);\n\n\tif (parsed.noSession) {\n\t\tsessionManager.disable();\n\t}\n\n\t// Handle --resume flag: show session selector\n\tif (parsed.resume) {\n\t\tconst selectedSession = await selectSession(sessionManager);\n\t\tif (!selectedSession) {\n\t\t\tconsole.log(chalk.dim(\"No session selected\"));\n\t\t\treturn;\n\t\t}\n\t\tsessionManager.setSessionFile(selectedSession);\n\t}\n\n\t// Resolve model scope early if provided\n\tlet scopedModels: ScopedModel[] = [];\n\tif (parsed.models && parsed.models.length > 0) {\n\t\tscopedModels = await resolveModelScope(parsed.models);\n\t}\n\n\t// Determine mode and output behavior\n\tconst isInteractive = !parsed.print && parsed.mode === undefined;\n\tconst mode = parsed.mode || \"text\";\n\tconst shouldPrintMessages = isInteractive;\n\n\t// Find initial model\n\tlet initialModel = await findInitialModelForSession(parsed, scopedModels, settingsManager);\n\tlet initialThinking: ThinkingLevel = \"off\";\n\n\t// Get thinking level from scoped models if applicable\n\tif (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {\n\t\tinitialThinking = scopedModels[0].thinkingLevel;\n\t} else {\n\t\t// Try saved thinking level\n\t\tconst savedThinking = settingsManager.getDefaultThinkingLevel();\n\t\tif (savedThinking) {\n\t\t\tinitialThinking = savedThinking;\n\t\t}\n\t}\n\n\t// Non-interactive mode: fail early if no model available\n\tif (!isInteractive && !initialModel) {\n\t\tconsole.error(chalk.red(\"No models available.\"));\n\t\tconsole.error(chalk.yellow(\"\\nSet an API key environment variable:\"));\n\t\tconsole.error(\" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.\");\n\t\tconsole.error(chalk.yellow(`\\nOr create ${getModelsPath()}`));\n\t\tprocess.exit(1);\n\t}\n\n\t// Non-interactive mode: validate API key exists\n\tif (!isInteractive && initialModel) {\n\t\tconst apiKey = parsed.apiKey || (await getApiKeyForModel(initialModel));\n\t\tif (!apiKey) {\n\t\t\tconsole.error(chalk.red(`No API key found for ${initialModel.provider}`));\n\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\n\t// Build system prompt\n\tconst systemPrompt = buildSystemPrompt(parsed.systemPrompt, parsed.tools, parsed.appendSystemPrompt);\n\n\t// Handle session restoration\n\tlet modelFallbackMessage: string | null = null;\n\n\tif (parsed.continue || parsed.resume) {\n\t\tconst savedModel = sessionManager.loadModel();\n\t\tif (savedModel) {\n\t\t\tconst result = await restoreModelFromSession(\n\t\t\t\tsavedModel.provider,\n\t\t\t\tsavedModel.modelId,\n\t\t\t\tinitialModel,\n\t\t\t\tshouldPrintMessages,\n\t\t\t);\n\n\t\t\tif (result.model) {\n\t\t\t\tinitialModel = result.model;\n\t\t\t}\n\t\t\tmodelFallbackMessage = result.fallbackMessage;\n\t\t}\n\n\t\t// Load and restore thinking level\n\t\tconst thinkingLevel = sessionManager.loadThinkingLevel() as ThinkingLevel;\n\t\tif (thinkingLevel) {\n\t\t\tinitialThinking = thinkingLevel;\n\t\t\tif (shouldPrintMessages) {\n\t\t\t\tconsole.log(chalk.dim(`Restored thinking level: ${thinkingLevel}`));\n\t\t\t}\n\t\t}\n\t}\n\n\t// CLI --thinking flag takes highest priority\n\tif (parsed.thinking) {\n\t\tinitialThinking = parsed.thinking;\n\t}\n\n\t// Determine which tools to use\n\tconst selectedTools = parsed.tools ? parsed.tools.map((name) => allTools[name]) : codingTools;\n\n\t// Create agent\n\tconst agent = new Agent({\n\t\tinitialState: {\n\t\t\tsystemPrompt,\n\t\t\tmodel: initialModel as any, // Can be null in interactive mode\n\t\t\tthinkingLevel: initialThinking,\n\t\t\ttools: selectedTools,\n\t\t},\n\t\tmessageTransformer,\n\t\tqueueMode: settingsManager.getQueueMode(),\n\t\ttransport: new ProviderTransport({\n\t\t\tgetApiKey: async () => {\n\t\t\t\tconst currentModel = agent.state.model;\n\t\t\t\tif (!currentModel) {\n\t\t\t\t\tthrow new Error(\"No model selected\");\n\t\t\t\t}\n\n\t\t\t\tif (parsed.apiKey) {\n\t\t\t\t\treturn parsed.apiKey;\n\t\t\t\t}\n\n\t\t\t\tconst key = await getApiKeyForModel(currentModel);\n\t\t\t\tif (!key) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`No API key found for provider \"${currentModel.provider}\". Please set the appropriate environment variable or update ${getModelsPath()}`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\treturn key;\n\t\t\t},\n\t\t}),\n\t});\n\n\t// If initial thinking was requested but model doesn't support it, reset to off\n\tif (initialThinking !== \"off\" && initialModel && !initialModel.reasoning) {\n\t\tagent.setThinkingLevel(\"off\");\n\t}\n\n\t// Load previous messages if continuing or resuming\n\tif (parsed.continue || parsed.resume) {\n\t\tconst messages = sessionManager.loadMessages();\n\t\tif (messages.length > 0) {\n\t\t\tagent.replaceMessages(messages);\n\t\t}\n\t}\n\n\t// Log loaded context files\n\tif (shouldPrintMessages && !parsed.continue && !parsed.resume) {\n\t\tconst contextFiles = loadProjectContextFiles();\n\t\tif (contextFiles.length > 0) {\n\t\t\tconsole.log(chalk.dim(\"Loaded project context from:\"));\n\t\t\tfor (const { path: filePath } of contextFiles) {\n\t\t\t\tconsole.log(chalk.dim(` - ${filePath}`));\n\t\t\t}\n\t\t}\n\t}\n\n\t// Load file commands for slash command expansion\n\tconst fileCommands = loadSlashCommands();\n\n\t// Create session\n\tconst session = new AgentSession({\n\t\tagent,\n\t\tsessionManager,\n\t\tsettingsManager,\n\t\tscopedModels,\n\t\tfileCommands,\n\t});\n\n\t// Route to appropriate mode\n\tif (mode === \"rpc\") {\n\t\tawait runRpcMode(session);\n\t} else if (isInteractive) {\n\t\t// Check for new version in the background\n\t\tconst versionCheckPromise = checkForNewVersion(VERSION).catch(() => null);\n\n\t\t// Check if we should show changelog\n\t\tconst changelogMarkdown = getChangelogForDisplay(parsed, settingsManager);\n\n\t\t// Show model scope if provided\n\t\tif (scopedModels.length > 0) {\n\t\t\tconst modelList = scopedModels\n\t\t\t\t.map((sm) => {\n\t\t\t\t\tconst thinkingStr = sm.thinkingLevel !== \"off\" ? `:${sm.thinkingLevel}` : \"\";\n\t\t\t\t\treturn `${sm.model.id}${thinkingStr}`;\n\t\t\t\t})\n\t\t\t\t.join(\", \");\n\t\t\tconsole.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray(\"(Ctrl+P to cycle)\")}`));\n\t\t}\n\n\t\t// Ensure fd tool is available for file autocomplete\n\t\tconst fdPath = await ensureTool(\"fd\");\n\n\t\tawait runInteractiveMode(\n\t\t\tsession,\n\t\t\tVERSION,\n\t\t\tchangelogMarkdown,\n\t\t\tmodelFallbackMessage,\n\t\t\tversionCheckPromise,\n\t\t\tparsed.messages,\n\t\t\tinitialMessage,\n\t\t\tinitialAttachments,\n\t\t\tfdPath,\n\t\t);\n\t} else {\n\t\t// Non-interactive mode (--print flag or --mode flag)\n\t\tawait runPrintMode(session, mode, parsed.messages, initialMessage, initialAttachments);\n\t}\n}\n\n/** Find initial model based on CLI args, scoped models, settings, or available models */\nasync function findInitialModelForSession(\n\tparsed: Args,\n\tscopedModels: ScopedModel[],\n\tsettingsManager: SettingsManager,\n) {\n\t// 1. CLI args take priority\n\tif (parsed.provider && parsed.model) {\n\t\tconst { model, error } = findModel(parsed.provider, parsed.model);\n\t\tif (error) {\n\t\t\tconsole.error(chalk.red(error));\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tif (!model) {\n\t\t\tconsole.error(chalk.red(`Model ${parsed.provider}/${parsed.model} not found`));\n\t\t\tprocess.exit(1);\n\t\t}\n\t\treturn model;\n\t}\n\n\t// 2. Use first model from scoped models (skip if continuing/resuming)\n\tif (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {\n\t\treturn scopedModels[0].model;\n\t}\n\n\t// 3. Try saved default from settings\n\tconst defaultProvider = settingsManager.getDefaultProvider();\n\tconst defaultModelId = settingsManager.getDefaultModel();\n\tif (defaultProvider && defaultModelId) {\n\t\tconst { model, error } = findModel(defaultProvider, defaultModelId);\n\t\tif (error) {\n\t\t\tconsole.error(chalk.red(error));\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tif (model) {\n\t\t\treturn model;\n\t\t}\n\t}\n\n\t// 4. Try first available model with valid API key\n\tconst { models: availableModels, error } = await getAvailableModels();\n\n\tif (error) {\n\t\tconsole.error(chalk.red(error));\n\t\tprocess.exit(1);\n\t}\n\n\tif (availableModels.length > 0) {\n\t\treturn availableModels[0];\n\t}\n\n\treturn null;\n}\n\n/** Get changelog markdown to display (only for new sessions with updates) */\nfunction getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | null {\n\tif (parsed.continue || parsed.resume) {\n\t\treturn null;\n\t}\n\n\tconst lastVersion = settingsManager.getLastChangelogVersion();\n\tconst changelogPath = getChangelogPath();\n\tconst entries = parseChangelog(changelogPath);\n\n\tif (!lastVersion) {\n\t\t// First run - show all entries\n\t\tif (entries.length > 0) {\n\t\t\tsettingsManager.setLastChangelogVersion(VERSION);\n\t\t\treturn entries.map((e) => e.content).join(\"\\n\\n\");\n\t\t}\n\t} else {\n\t\t// Check for new entries since last version\n\t\tconst newEntries = getNewEntries(entries, lastVersion);\n\t\tif (newEntries.length > 0) {\n\t\t\tsettingsManager.setLastChangelogVersion(VERSION);\n\t\t\treturn newEntries.map((e) => e.content).join(\"\\n\\n\");\n\t\t}\n\t}\n\n\treturn null;\n}\n"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-5","usage":{"input":1,"output":4397,"cacheRead":116549,"cacheWrite":3580,"totalTokens":124527,"cost":{"input":0.000005,"output":0.10992500000000001,"cacheRead":0.0582745,"cacheWrite":0.022375000000000003,"total":0.1905795}},"stopReason":"toolUse","timestamp":1765239542909}}
packages/coding-agent/test/fixtures/before-compaction.jsonl:830:{"type":"message","timestamp":"2025-12-09T00:22:31.652Z","message":{"role":"assistant","content":[{"type":"text","text":"Now let me create DEVELOPMENT.md for the coding-agent package:"},{"type":"toolCall","id":"toolu_016v3zn8gbaRAfkxb1Tv4pLn","name":"write","arguments":{"path":"/Users/badlogic/workspaces/pi-mono/packages/coding-agent/DEVELOPMENT.md","content":"# coding-agent Development Guide\n\nThis document describes the architecture and development workflow for the coding-agent package.\n\n## Architecture Overview\n\nThe coding-agent is structured into distinct layers:\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│ CLI Layer │\n│ cli.ts → main.ts → cli/args.ts, cli/file-processor.ts │\n└─────────────────────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────────────────────┐\n│ Mode Layer │\n│ modes/interactive/ modes/print-mode.ts modes/rpc-mode.ts │\n└─────────────────────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────────────────────┐\n│ Core Layer │\n│ core/agent-session.ts (central abstraction) │\n│ core/session-manager.ts, core/model-config.ts, etc. │\n└─────────────────────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────────────────────┐\n│ External Dependencies │\n│ @mariozechner/pi-agent-core (Agent, tools) │\n│ @mariozechner/pi-ai (models, providers) │\n│ @mariozechner/pi-tui (TUI components) │\n└─────────────────────────────────────────────────────────────┘\n```\n\n## Directory Structure\n\n```\nsrc/\n├── cli.ts # CLI entry point (shebang, calls main)\n├── main.ts # Main orchestration, argument handling, mode routing\n├── index.ts # Public API exports\n\n├── cli/ # CLI-specific utilities\n│ ├── args.ts # parseArgs(), printHelp(), Args interface\n│ ├── file-processor.ts # processFileArguments() for @file args\n│ └── session-picker.ts # selectSession() TUI for --resume\n\n├── core/ # Core business logic (mode-agnostic)\n│ ├── agent-session.ts # AgentSession class - THE central abstraction\n│ ├── bash-executor.ts # executeBash() with streaming, abort\n│ ├── compaction.ts # Context compaction logic\n│ ├── export-html.ts # exportSession(), exportFromFile()\n│ ├── messages.ts # BashExecutionMessage, messageTransformer\n│ ├── model-config.ts # findModel(), getAvailableModels(), getApiKeyForModel()\n│ ├── model-resolver.ts # resolveModelScope(), restoreModelFromSession()\n│ ├── session-manager.ts # SessionManager class - JSONL persistence\n│ ├── settings-manager.ts # SettingsManager class - user preferences\n│ ├── slash-commands.ts # loadSlashCommands() from ~/.pi/agent/commands/\n│ ├── system-prompt.ts # buildSystemPrompt(), loadProjectContextFiles()\n│ ├── oauth/ # OAuth authentication (Anthropic, etc.)\n│ │ ├── anthropic.ts\n│ │ ├── storage.ts\n│ │ └── index.ts\n│ └── tools/ # Tool implementations\n│ ├── bash.ts, edit.ts, find.ts, grep.ts, ls.ts, read.ts, write.ts\n│ ├── truncate.ts # Output truncation utilities\n│ └── index.ts # Tool exports, allTools, codingTools\n\n├── modes/ # Run mode implementations\n│ ├── index.ts # Re-exports InteractiveMode, runPrintMode, runRpcMode\n│ ├── print-mode.ts # Non-interactive: process messages, print output, exit\n│ ├── rpc-mode.ts # JSON-RPC mode for programmatic control\n│ └── interactive/ # Interactive TUI mode\n│ ├── interactive-mode.ts # InteractiveMode class\n│ ├── components/ # TUI components (editor, selectors, etc.)\n│ │ ├── assistant-message.ts\n│ │ ├── bash-execution.ts\n│ │ ├── custom-editor.ts\n│ │ ├── footer.ts\n│ │ ├── model-selector.ts\n│ │ ├── session-selector.ts\n│ │ └── ... (other selectors)\n│ └── theme/\n│ ├── theme.ts # Theme loading, getEditorTheme(), etc.\n│ ├── dark.json\n│ ├── light.json\n│ └── theme-schema.json\n\n└── utils/ # Generic utilities\n ├── changelog.ts # parseChangelog(), getNewEntries()\n ├── clipboard.ts # copyToClipboard()\n ├── config.ts # APP_NAME, VERSION, paths (getAgentDir, etc.)\n ├── fuzzy.ts # Fuzzy string matching\n ├── shell.ts # getShellConfig()\n └── tools-manager.ts # ensureTool() - download fd, etc.\n```\n\n## Key Abstractions\n\n### AgentSession (core/agent-session.ts)\n\nThe central abstraction that wraps the low-level `Agent` with:\n- Session persistence (via SessionManager)\n- Settings persistence (via SettingsManager)\n- Model cycling with scoped models\n- Context compaction\n- Bash command execution\n- Message queuing\n\nAll three modes (interactive, print, rpc) use AgentSession.\n\n### InteractiveMode (modes/interactive/interactive-mode.ts)\n\nHandles TUI rendering and user interaction:\n- Subscribes to AgentSession events\n- Renders messages, tool executions, streaming\n- Manages editor, selectors, key handlers\n- Delegates all business logic to AgentSession\n\n### SessionManager (core/session-manager.ts)\n\nHandles session persistence:\n- JSONL format for append-only writes\n- Session file location management\n- Message loading/saving\n- Model/thinking level persistence\n\n### SettingsManager (core/settings-manager.ts)\n\nHandles user preferences:\n- Default model/provider\n- Theme selection\n- Queue mode\n- Thinking block visibility\n\n## Development Workflow\n\n### Running in Development\n\n```bash\n# From monorepo root\nnpx tsx packages/coding-agent/src/cli.ts\n\n# With arguments\nnpx tsx packages/coding-agent/src/cli.ts --help\nnpx tsx packages/coding-agent/src/cli.ts -p \"Hello\"\n```\n\n### Type Checking\n\n```bash\n# From monorepo root\nnpm run check\n```\n\n### Building\n\n```bash\n# Build all packages\nnpm run build\n\n# Build standalone binary\ncd packages/coding-agent\nnpm run build:binary\n```\n\n## Adding New Features\n\n### Adding a New Slash Command\n\n1. If it's a UI-only command (e.g., `/theme`), add handler in `interactive-mode.ts` `setupEditorSubmitHandler()`\n2. If it needs session logic, add method to `AgentSession` and call from mode\n\n### Adding a New Tool\n\n1. Create tool in `core/tools/` following existing patterns\n2. Export from `core/tools/index.ts`\n3. Add to `allTools` and optionally `codingTools`\n4. Add description to `toolDescriptions` in `core/system-prompt.ts`\n\n### Adding a New Selector\n\n1. Create component in `modes/interactive/components/`\n2. Use `showSelector()` helper in `interactive-mode.ts`:\n\n```typescript\nprivate showMySelector(): void {\n this.showSelector((done) => {\n const selector = new MySelectorComponent(\n // ... params\n (result) => {\n // Handle selection\n done();\n this.showStatus(`Selected: ${result}`);\n },\n () => {\n done();\n this.ui.requestRender();\n },\n );\n return { component: selector, focus: selector.getSelectList() };\n });\n}\n```\n\n## Testing\n\nThe package uses E2E tests only (no unit tests by design). Tests are in `test/`:\n\n```bash\n# Run tests\nnpm test\n```\n\n## Code Style\n\n- No `any` types unless absolutely necessary\n- No inline dynamic imports\n- Use `showStatus()` for dim status messages\n- Use `showError()` / `showWarning()` for errors/warnings\n- Keep InteractiveMode focused on UI, delegate logic to AgentSession\n"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-5","usage":{"input":1,"output":2269,"cacheRead":129988,"cacheWrite":862,"totalTokens":133120,"cost":{"input":0.000005,"output":0.056725000000000005,"cacheRead":0.064994,"cacheWrite":0.0053875,"total":0.1271115}},"stopReason":"toolUse","timestamp":1765239716696}}
packages/coding-agent/test/fixtures/before-compaction.jsonl:904:{"type":"message","timestamp":"2025-12-09T00:31:04.026Z","message":{"role":"toolResult","toolCallId":"toolu_01EharQV2qhB7B7rMmnbQEX1","toolName":"read","content":[{"type":"text","text":"\tmodel: Model<any>;\n\tthinkingLevel: ThinkingLevel;\n\t/** Whether cycling through scoped models (--models flag) or all available */\n\tisScoped: boolean;\n}\n\n/** Result from compact() or checkAutoCompaction() */\nexport interface CompactionResult {\n\ttokensBefore: number;\n\tsummary: string;\n}\n\n/** Session statistics for /session command */\nexport interface SessionStats {\n\tsessionFile: string;\n\tsessionId: string;\n\tuserMessages: number;\n\tassistantMessages: number;\n\ttoolCalls: number;\n\ttoolResults: number;\n\ttotalMessages: number;\n\ttokens: {\n\t\tinput: number;\n\t\toutput: number;\n\t\tcacheRead: number;\n\t\tcacheWrite: number;\n\t\ttotal: number;\n\t};\n\tcost: number;\n}\n\n// ============================================================================\n// AgentSession Class\n// ============================================================================\n\nexport class AgentSession {\n\treadonly agent: Agent;\n\treadonly sessionManager: SessionManager;\n\treadonly settingsManager: SettingsManager;\n\n\tprivate _scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;\n\tprivate _fileCommands: FileSlashCommand[];\n\n\t// Event subscription state\n\tprivate _unsubscribeAgent?: () => void;\n\n[787 more lines in file. Use offset=100 to continue]"}],"isError":false,"timestamp":1765240264017}}
packages/coding-agent/test/fixtures/before-compaction.jsonl:906:{"type":"message","timestamp":"2025-12-09T00:31:08.447Z","message":{"role":"toolResult","toolCallId":"toolu_0151owDVLZMjGL7UeMHv3SnC","toolName":"read","content":[{"type":"text","text":"\tprivate _scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;\n\tprivate _fileCommands: FileSlashCommand[];\n\n\t// Event subscription state\n\tprivate _unsubscribeAgent?: () => void;\n\tprivate _eventListeners: AgentEventListener[] = [];\n\n\t// Message queue state\n\tprivate _queuedMessages: string[] = [];\n\n\t// Compaction state\n\tprivate _compactionAbortController: AbortController | null = null;\n\n\t// Bash execution state\n\tprivate _bashAbortController: AbortController | null = null;\n\n\tconstructor(config: AgentSessionConfig) {\n\t\tthis.agent = config.agent;\n\t\tthis.sessionManager = config.sessionManager;\n\t\tthis.settingsManager = config.settingsManager;\n\t\tthis._scopedModels = config.scopedModels ?? [];\n\t\tthis._fileCommands = config.fileCommands ?? [];\n\t}\n\n\t// =========================================================================\n\t// Event Subscription\n\t// =========================================================================\n\n\t/**\n\t * Subscribe to agent events.\n\t * Session persistence is handled internally (saves messages on message_end).\n\t * Multiple listeners can be added. Returns unsubscribe function for this listener.\n\t */\n\tsubscribe(listener: AgentEventListener): () => void {\n\t\tthis._eventListeners.push(listener);\n\n\t\t// Set up agent subscription if not already done\n\t\tif (!this._unsubscribeAgent) {\n\t\t\tthis._unsubscribeAgent = this.agent.subscribe(async (event) => {\n\t\t\t\t// Notify all listeners\n\n[752 more lines in file. Use offset=135 to continue]"}],"isError":false,"timestamp":1765240268439}}
packages/coding-agent/src/modes/interactive/interactive-mode.ts:82:import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.js";
packages/coding-agent/src/modes/interactive/interactive-mode.ts:271: this.session.scopedModels.length > 0
packages/coding-agent/src/modes/interactive/interactive-mode.ts:272: ? this.session.scopedModels.map((s) => s.model)
packages/coding-agent/src/modes/interactive/interactive-mode.ts:296: { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" },
packages/coding-agent/src/modes/interactive/interactive-mode.ts:1474: if (text === "/scoped-models") {
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2240: const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available";
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2693: if (this.session.scopedModels.length > 0) {
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2694: return this.session.scopedModels.map((scoped) => scoped.model);
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2712: this.session.scopedModels,
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2745: // Check if session has scoped models (from previous session-only changes or CLI --models)
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2746: const sessionScopedModels = this.session.scopedModels;
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2754: // Use current session's scoped models
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2764: const scopedModels = await resolveModelScope(patterns, this.session.modelRegistry);
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2765: for (const sm of scopedModels) {
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2775: // Helper to update session's scoped models (session-only, no persist)
packages/coding-agent/test/model-resolver.test.ts:228: scopedModels: [],
packages/coding-agent/src/core/model-resolver.ts:181: const scopedModels: ScopedModel[] = [];
packages/coding-agent/src/core/model-resolver.ts:212: if (!scopedModels.find((sm) => modelsAreEqual(sm.model, model))) {
packages/coding-agent/src/core/model-resolver.ts:213: scopedModels.push({ model, thinkingLevel });
packages/coding-agent/src/core/model-resolver.ts:231: if (!scopedModels.find((sm) => modelsAreEqual(sm.model, model))) {
packages/coding-agent/src/core/model-resolver.ts:232: scopedModels.push({ model, thinkingLevel });
packages/coding-agent/src/core/model-resolver.ts:236: return scopedModels;
packages/coding-agent/src/core/model-resolver.ts:248: * 2. First model from scoped models (if not continuing/resuming)
packages/coding-agent/src/core/model-resolver.ts:256: scopedModels: ScopedModel[];
packages/coding-agent/src/core/model-resolver.ts:266: scopedModels,
packages/coding-agent/src/core/model-resolver.ts:287: // 2. Use first model from scoped models (skip if continuing/resuming)
packages/coding-agent/src/core/model-resolver.ts:288: if (scopedModels.length > 0 && !isContinuing) {
packages/coding-agent/src/core/model-resolver.ts:290: model: scopedModels[0].model,
packages/coding-agent/src/core/model-resolver.ts:291: thinkingLevel: scopedModels[0].thinkingLevel ?? defaultThinkingLevel ?? "off",
[Showing lines 2-36 of 36 (50.0KB limit). Full output: /var/folders/49/l4171l3n219_xwq30fmyzr8w0000gn/T/pi-bash-f061036ec6f3188a.log]Preparing to read key source files
I'm identifying the main files to read in full—interactive mode and scoped models selector components—to analyze the relevant code without truncation, ensuring full content retrieval despite potential file size.
>ls packages/coding-agent/src/modes/interactive/componentsarmin.ts
assistant-message.ts
bash-execution.ts
bordered-loader.ts
branch-summary-message.ts
compaction-summary-message.ts
countdown-timer.ts
custom-editor.ts
custom-message.ts
diff.ts
dynamic-border.ts
extension-editor.ts
extension-input.ts
extension-selector.ts
footer.ts
index.ts
keybinding-hints.ts
login-dialog.ts
model-selector.ts
oauth-selector.ts
scoped-models-selector.ts
session-selector-search.ts
session-selector.ts
settings-selector.ts
show-images-selector.ts
theme-selector.ts
thinking-selector.ts
tool-execution.ts
tree-selector.ts
user-message-selector.ts
user-message.ts
visual-truncate.tsReading full interactive mode
Planning multi-part file reading
The full file exceeds 50KB, so I'll read it in parts using offsets despite the general guideline against offset/limit for full reads; this approach ensures complete coverage without truncation.
Identifying key files to review
I'm targeting related files like scoped-models-selector.ts, model-selector.ts, agent-session.ts, settings-manager, model registry, and core/model-resolver for reading, focusing on functions like resolveModelScope and usage of modelRegistry in interactive-mode.
Reading model selectors and settings manager
I'm examining the scoped-models-selector, model-selector, and settings manager to understand how model persistence and enabled models interact, focusing on the scoped models and their effect on model visibility.
Identifying need for model-selector component
Reviewing settings-manager for enabled models
I'm checking the settings-manager and model-registry to understand how getEnabledModels settings are accessed and used.
Investigating model enabling and scoping
I'm checking where settings for enabled models are used to set session scopedModels, which affects /model API behavior when scopedModels is not empty. I'll search for how and when enabledModels updates scopedModels, likely around startup.
>rg -n "enabledModels|setEnabledModels|getEnabledModels|scopedModels" packages/coding-agent/srcpackages/coding-agent/src/main.ts:212: scopedModels: ScopedModel[],
packages/coding-agent/src/main.ts:240: } else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
packages/coding-agent/src/main.ts:245: const savedInScope = savedModel ? scopedModels.find((sm) => modelsAreEqual(sm.model, savedModel)) : undefined;
packages/coding-agent/src/main.ts:254: options.model = scopedModels[0].model;
packages/coding-agent/src/main.ts:256: if (!parsed.thinking && scopedModels[0].thinkingLevel) {
packages/coding-agent/src/main.ts:257: options.thinkingLevel = scopedModels[0].thinkingLevel;
packages/coding-agent/src/main.ts:268: if (scopedModels.length > 0) {
packages/coding-agent/src/main.ts:270: options.scopedModels = scopedModels.map((sm) => ({
packages/coding-agent/src/main.ts:429: let scopedModels: ScopedModel[] = [];
packages/coding-agent/src/main.ts:430: const modelPatterns = parsed.models ?? settingsManager.getEnabledModels();
packages/coding-agent/src/main.ts:432: scopedModels = await resolveModelScope(modelPatterns, modelRegistry);
packages/coding-agent/src/main.ts:460: scopedModels,
packages/coding-agent/src/main.ts:507: if (scopedModels.length > 0 && !settingsManager.getQuietStartup()) {
packages/coding-agent/src/main.ts:508: const modelList = scopedModels
packages/coding-agent/src/core/agent-session.ts:89: scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
packages/coding-agent/src/core/agent-session.ts:165: private _scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
packages/coding-agent/src/core/agent-session.ts:220: this._scopedModels = config.scopedModels ?? [];
packages/coding-agent/src/core/agent-session.ts:542: get scopedModels(): ReadonlyArray<{ model: Model<any>; thinkingLevel: ThinkingLevel }> {
packages/coding-agent/src/core/agent-session.ts:543: return this._scopedModels;
packages/coding-agent/src/core/agent-session.ts:547: setScopedModels(scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>): void {
packages/coding-agent/src/core/agent-session.ts:548: this._scopedModels = scopedModels;
packages/coding-agent/src/core/agent-session.ts:1074: if (this._scopedModels.length > 0) {
packages/coding-agent/src/core/agent-session.ts:1081: if (this._scopedModels.length <= 1) return undefined;
packages/coding-agent/src/core/agent-session.ts:1084: let currentIndex = this._scopedModels.findIndex((sm) => modelsAreEqual(sm.model, currentModel));
packages/coding-agent/src/core/agent-session.ts:1087: const len = this._scopedModels.length;
packages/coding-agent/src/core/agent-session.ts:1089: const next = this._scopedModels[nextIndex];
packages/coding-agent/src/core/settings-manager.ts:70: enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag)
packages/coding-agent/src/core/settings-manager.ts:468: getEnabledModels(): string[] | undefined {
packages/coding-agent/src/core/settings-manager.ts:469: return this.settings.enabledModels;
packages/coding-agent/src/core/settings-manager.ts:472: setEnabledModels(patterns: string[] | undefined): void {
packages/coding-agent/src/core/settings-manager.ts:473: this.globalSettings.enabledModels = patterns;
packages/coding-agent/src/modes/interactive/interactive-mode.ts:271: this.session.scopedModels.length > 0
packages/coding-agent/src/modes/interactive/interactive-mode.ts:272: ? this.session.scopedModels.map((s) => s.model)
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2240: const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available";
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2693: if (this.session.scopedModels.length > 0) {
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2694: return this.session.scopedModels.map((scoped) => scoped.model);
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2712: this.session.scopedModels,
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2746: const sessionScopedModels = this.session.scopedModels;
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2761: const patterns = this.settingsManager.getEnabledModels();
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2764: const scopedModels = await resolveModelScope(patterns, this.session.modelRegistry);
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2765: for (const sm of scopedModels) {
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2840: this.settingsManager.setEnabledModels(newPatterns);
packages/coding-agent/src/core/sdk.ts:95: scopedModels?: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }>;
packages/coding-agent/src/core/sdk.ts:666: scopedModels: options.scopedModels,
packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts:74: /** true if enabledModels setting is defined (empty = all enabled) */
packages/coding-agent/src/modes/interactive/components/model-selector.ts:54: private scopedModels: ReadonlyArray<ScopedModelItem>;
packages/coding-agent/src/modes/interactive/components/model-selector.ts:61: scopedModels: ReadonlyArray<ScopedModelItem>,
packages/coding-agent/src/modes/interactive/components/model-selector.ts:72: this.scopedModels = scopedModels;
packages/coding-agent/src/modes/interactive/components/model-selector.ts:82: scopedModels.length > 0
packages/coding-agent/src/modes/interactive/components/model-selector.ts:128: if (this.scopedModels.length > 0) {
packages/coding-agent/src/modes/interactive/components/model-selector.ts:129: models = this.scopedModels.map((scoped) => ({
packages/coding-agent/src/core/model-resolver.ts:181: const scopedModels: ScopedModel[] = [];
packages/coding-agent/src/core/model-resolver.ts:212: if (!scopedModels.find((sm) => modelsAreEqual(sm.model, model))) {
packages/coding-agent/src/core/model-resolver.ts:213: scopedModels.push({ model, thinkingLevel });
packages/coding-agent/src/core/model-resolver.ts:231: if (!scopedModels.find((sm) => modelsAreEqual(sm.model, model))) {
packages/coding-agent/src/core/model-resolver.ts:232: scopedModels.push({ model, thinkingLevel });
packages/coding-agent/src/core/model-resolver.ts:236: return scopedModels;
packages/coding-agent/src/core/model-resolver.ts:256: scopedModels: ScopedModel[];
packages/coding-agent/src/core/model-resolver.ts:266: scopedModels,
packages/coding-agent/src/core/model-resolver.ts:288: if (scopedModels.length > 0 && !isContinuing) {
packages/coding-agent/src/core/model-resolver.ts:290: model: scopedModels[0].model,
packages/coding-agent/src/core/model-resolver.ts:291: thinkingLevel: scopedModels[0].thinkingLevel ?? defaultThinkingLevel ?? "off",Prioritizing main.ts review
I'm focusing on main.ts to understand how interactive scoped models persist in settings and will check core/sdk next if needed.
Analyzing model scoping and persistence
The /model endpoint uses session scoped models derived from saved enabled models in settings, causing it to show only scoped models rather than all. Persisting changes updates settings but doesn't refresh session scoped models immediately, leading to this mismatch. A fix should ensure /model shows all models or session scoped models update consistently after persistence.
Distinguishing cycling scope from model filtering
The current conflation of session scoped models for both cycling (/scoped-models) and /model filtering causes /model to wrongly limit visible models. To fix this, cycling scope from settings should remain separate from /model filtering, which should show all models unless explicitly limited by CLI --models. Adding distinct session flags for CLI scope versus cycling scope and adjusting the model selector to differentiate these will align behavior with expectations.
Identifying related model-resolver code
The scoped models persist via enabledModels in settings affecting main scopedModels, which incorrectly impacts /model instead of just cycling. I’ll review model-resolver next to clarify its role in scopes and dependencies.
Analyzing model scoping root cause
The root cause is that the /model selector relies on session scoped models created from persisted enabledModels settings, restricting the displayed list. The fix involves distinguishing scoped models by source, only restricting /model when scoped from CLI, and allowing full listing otherwise by tagging scopedModels with a source flag.
Proposing scoped models source tracking
The fix is to track the source of scoped models in the session—distinguishing CLI --models (restricting /model list) from settings-based scopes (limiting only cycling). This involves adding a scope source flag in the session, updating interactive mode and model selector to use full model list except when scoped by CLI, and adjusting hint text accordingly to avoid unintended filtering on /model.
Issue 844 analysis
Type: bug
Linked issues or PRs: none.
What happens in code
parsed.models ?? settingsManager.getEnabledModels() to build scopedModels via resolveModelScope.scopedModels, and AgentSession stores them in _scopedModels.session.scopedModels to build the candidate list in:
--models scope, so /model only shows the scoped set and shows the “Showing models from --models scope” hint.Actual root cause
The same scopedModels list is used for two different purposes:
scopedModels on startup, so /model is unintentionally filtered.Proposed fix Separate “cycling scope” from “model selector scope”.
Concise implementation approach
modelScopeSource: "cli" | "settings" | "session" | "none"isModelSelectionScoped.scopedModels:
parsed.models set to clisettings/scoped-models session-only updates set to session.cli.Affected files and changes
isModelSelectionScoped.This keeps /scoped-models as a Ctrl+P preference while preserving --models as a hard scope for /model when explicitly requested.